mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
The badge alone left the row in NEEDS YOU, which is the thing the issue was about. The fix is the alert that does not fire. An idle prompt from a session that is watching its own background work now opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to `notePrompt()`, which sets `acknowledgedAt` and records why in a new `acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", and the prompt itself stays pending, answerable and available as Read My Mind context. A wrong label therefore costs a card that does not blink, never an alert that was never created. Every surface follows from that. The broadcast carries the reason, so a live page declines to arm the tab alert and raises no desktop notification. The push is skipped, since a false alarm is hardest to ignore on a phone. A reloading page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And `classifySession()` now reads it too, which is a pre-existing bug fixed here: acknowledging on one device cleared the alert everywhere except `codeman tui`. It re-arms for free, because the next idle prompt supersedes the item and is built fresh. Only `idle` is eligible, so a dialog that blocks the agent still goes red whatever else it started. The label is pane-derived and therefore prompt-injectable, so it is now read from the last two rows of the screen only, with Claude's pattern anchored on the `·` its footer joins items with, ANSI-stripped and length-capped at the source. An agent that prints `· 1 monitor ·` into its own output finds no match. Verified on an isolated beta: a session that armed a monitor took its idle prompt acknowledged with no alert on any surface, wore the badge, and showed "quiet, watching 1 monitor" on its still-answerable card; the same session with the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts` pins both directions across all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
6.3 KiB
TypeScript
150 lines
6.3 KiB
TypeScript
/**
|
||
* @fileoverview Pure working/idle heuristics for a Claude interactive pane.
|
||
*
|
||
* Split out of `session.ts` so the thresholds and the state math are unit
|
||
* testable without a PTY (same reasoning as `session-order.ts` /
|
||
* `usage-limit-patterns.ts`).
|
||
*
|
||
* **Why activity and not the status line.** Claude Code's working indicator is
|
||
* `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`, where the glyph animates through
|
||
* `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn. Neither the braille
|
||
* spinner (`SPINNER_PATTERN`) nor the old keyword list (`Thinking|Writing|
|
||
* Reading|Running`) matches any of that, so the pane looked idle for a whole
|
||
* turn. Matching the new line does not rescue the stream either: tmux ships
|
||
* PARTIAL repaints, so measured on a live worker the complete line reached the
|
||
* PTY roughly once every 20 seconds, while the composer's `❯` (which is what
|
||
* ARMS idle detection) arrived every single second.
|
||
*
|
||
* What is left is the one thing measured to separate the two states cleanly: a
|
||
* working pane repaints, an idle pane emits nothing at all. Sampled once per
|
||
* second for 12s across six live sessions, the two working ones produced output
|
||
* in 12/12 windows and the four idle ones in 0/12.
|
||
*/
|
||
|
||
import { stripAnsi } from './utils/regex-patterns.js';
|
||
|
||
/**
|
||
* A gap longer than this ends a run of continuous output. Claude repaints at
|
||
* least once a second while working, so this leaves generous headroom.
|
||
*/
|
||
export const ACTIVITY_GAP_MS = 2000;
|
||
|
||
/**
|
||
* Continuous output for this long means the pane is working. Long enough that a
|
||
* one-off repaint (an update-check line, a rotating tip) cannot reach it.
|
||
*/
|
||
export const WORKING_STREAK_MS = 2000;
|
||
|
||
/**
|
||
* Silence for this long is what confirms the pane really went idle. Must stay
|
||
* above ACTIVITY_GAP_MS, or a pause between two repaints of one turn would
|
||
* read as the end of the turn.
|
||
*/
|
||
export const IDLE_SILENCE_MS = 2500;
|
||
|
||
/** How often a pending idle confirmation re-checks a pane that is still noisy. */
|
||
export const IDLE_RECHECK_MS = 500;
|
||
|
||
/**
|
||
* Floor between two pane probes for one session. The probe shells out to tmux,
|
||
* so this is what keeps a screenful of busy sessions from turning idle detection
|
||
* into a subprocess storm.
|
||
*/
|
||
export const PANE_PROBE_MIN_INTERVAL_MS = 1500;
|
||
|
||
/**
|
||
* How long to wait before looking again at a pane the probe just called working.
|
||
* Claude can sit silent for tens of seconds inside one tool call, so this is the
|
||
* cadence that carries a long quiet turn, so it is deliberately slow.
|
||
*/
|
||
export const PANE_PROBE_RECHECK_MS = 5000;
|
||
|
||
/** An unbroken run of PTY output. */
|
||
export interface ActivityStreak {
|
||
/** When this run began. */
|
||
startedAt: number;
|
||
/** The most recent chunk in it. */
|
||
lastAt: number;
|
||
}
|
||
|
||
/**
|
||
* Fold one output chunk into the current streak, starting a new one when the
|
||
* pane has been quiet longer than `gapMs`.
|
||
*/
|
||
export function trackActivityStreak(
|
||
streak: ActivityStreak | null,
|
||
now: number,
|
||
gapMs: number = ACTIVITY_GAP_MS
|
||
): ActivityStreak {
|
||
if (!streak || now - streak.lastAt > gapMs) return { startedAt: now, lastAt: now };
|
||
return { startedAt: streak.startedAt, lastAt: now };
|
||
}
|
||
|
||
/**
|
||
* True once a streak has been running long enough to mean work rather than a
|
||
* single repaint. Measured on the streak's own span (`lastAt - startedAt`), not
|
||
* against the caller's clock, so a stale streak cannot age into a true.
|
||
*/
|
||
export function isSustainedActivity(streak: ActivityStreak | null, streakMs: number = WORKING_STREAK_MS): boolean {
|
||
return !!streak && streak.lastAt - streak.startedAt >= streakMs;
|
||
}
|
||
|
||
/** True when the pane has produced nothing for long enough to call it idle. */
|
||
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
|
||
return now - lastActivityAt >= silenceMs;
|
||
}
|
||
|
||
/**
|
||
* How many lines at the foot of a pane capture may hold the background-work chip.
|
||
*
|
||
* Claude Code draws that chip on the last row of the screen. The row above it is the
|
||
* status line, which a user's own `statusLine` command writes, and two lines is what
|
||
* covers the chip wherever a trailing blank or a one-line notice pushes it up by one.
|
||
*
|
||
* ⚠️ The ceiling is the security boundary, not a tidiness measure. The label is
|
||
* PANE-DERIVED, so everything on that screen above the footer is text an agent wrote
|
||
* itself, and an agent that printed `· 1 monitor ·` into its own output would silence
|
||
* its own idle alert. Keep the window at the footer, keep each CLI's pattern anchored
|
||
* on the separator its footer actually uses, and never widen this to a whole-pane
|
||
* search.
|
||
*/
|
||
export const WATCHING_TAIL_LINES = 2;
|
||
|
||
/** Longest label a badge will carry. A footer chip is a handful of words. */
|
||
export const MAX_WATCHING_LABEL_CHARS = 40;
|
||
|
||
/**
|
||
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
|
||
*
|
||
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
|
||
* started is still going, which is exactly the case where the agent has ended its turn
|
||
* without wanting anything from the user. `pattern` comes from the CLI's own registry
|
||
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
|
||
* declares one, and the whole match stands in when it does not.
|
||
*
|
||
* Each candidate line is tested on its own, bottom row first, so a pattern can anchor
|
||
* itself with `^` or `$` against a single row rather than against a joined block. The
|
||
* answer is stripped of ANSI and capped, because it ends up on a badge and in an
|
||
* approval card.
|
||
*
|
||
* @returns the label, or null when the pane shows no background work
|
||
*/
|
||
export function watchingLabel(paneText: string | null | undefined, pattern: RegExp): string | null {
|
||
if (!paneText) return null;
|
||
const lines = stripAnsi(paneText)
|
||
.split('\n')
|
||
.map((line) => line.trimEnd())
|
||
.filter((line) => line !== '');
|
||
for (const line of lines.slice(-WATCHING_TAIL_LINES).reverse()) {
|
||
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
|
||
// caller reaching in from a test or a config reload might, and a stale lastIndex
|
||
// would make the same screen match every other call.
|
||
pattern.lastIndex = 0;
|
||
const match = pattern.exec(line);
|
||
if (!match) continue;
|
||
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
|
||
if (label) return label;
|
||
}
|
||
return null;
|
||
}
|