mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
While a codex dialog was up, the session re-read its screen every 2 s with a synchronous `tmux capture-pane`, for as long as the dialog stayed up. That read costs about 8 ms of blocked event loop (measured 2026-10-14), and an approval can sit unanswered overnight: 1,800 execs an hour per waiting session, measured as 300 in 10 quiet minutes in the unit test. A dialog leaves the screen only through a repaint, and tmux sends every repaint to the session's client as PTY output. So while the last reading saw the dialog and nothing has been drawn since, it is still up and the read is skipped; a safety-net read still runs every 30 s (BLOCKED_QUIET_RECHECK_MS). The clear keeps its 2 to 4 s latency, because the answer itself is output. Never skipped once a reading has missed the dialog: tmux updates the pane before it sends the repaint, and the confirming read must not wait for output that may already have arrived. The session-state tests now change the fake screen the way a real pane changes (with output), and two new tests pin the skip and the confirming read (the second fails with the guard removed). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
288 lines
13 KiB
TypeScript
288 lines
13 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 rows at the foot of a pane capture may hold the background-work row, for a
|
||
* CLI that declares no number of its own (`capabilities.workDetect.watchingLines`).
|
||
*
|
||
* One, because the tightest window is the right default and Claude Code needs no more:
|
||
* it draws its chip on the LAST row of the screen. Blank rows are dropped before the
|
||
* window is taken, so a trailing blank costs nothing, and a CLI that ever prints a row
|
||
* BELOW its chip loses the badge rather than gaining a hole.
|
||
*
|
||
* ⚠️ The size of this window is a trust boundary, not a tidiness measure, and the row
|
||
* it excludes first is the one that taught us so: Claude's status line sits directly
|
||
* above the footer, its content comes from a `statusLine` command, and a session running
|
||
* with permissions bypassed can write that command into `.claude/settings.json` in its
|
||
* own workspace. A window of two therefore let an agent print `· 1 monitor ·` onto a row
|
||
* of its own and silence its own idle alert. Every row added here is another row
|
||
* somebody may be able to write, so widen this only for a CLI whose layout forces it,
|
||
* and never to a whole-pane search.
|
||
*/
|
||
export const WATCHING_TAIL_LINES = 1;
|
||
|
||
/** 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 row 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. Blank
|
||
* rows are dropped before the window is taken, because a CLI that leaves a blank line
|
||
* between its chrome rows would otherwise spend the window on nothing. The answer is
|
||
* stripped of ANSI and capped, because it ends up on a badge and in an approval card.
|
||
*
|
||
* @param tailLines how many non-blank rows from the bottom to look at, defaulting to
|
||
* `WATCHING_TAIL_LINES`; a CLI declares its own when its row is not the last one
|
||
* @returns the label, or null when the pane shows no background work
|
||
*/
|
||
export function watchingLabel(
|
||
paneText: string | null | undefined,
|
||
pattern: RegExp,
|
||
tailLines: number = WATCHING_TAIL_LINES
|
||
): 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(-Math.max(1, tailLines)).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;
|
||
}
|
||
|
||
/**
|
||
* How many non-blank rows at the foot of a pane capture `isBlockedScreen()` reads for a
|
||
* CLI that declares no number of its own (`capabilities.workDetect.blockedLines`).
|
||
*/
|
||
export const BLOCKED_TAIL_LINES = 1;
|
||
|
||
/** Hard cap on that window, whatever a registry entry asks for (the schema's own max). */
|
||
export const BLOCKED_MAX_TAIL_LINES = 24;
|
||
|
||
/**
|
||
* How often a session that is showing a blocking dialog re-reads its screen to learn the
|
||
* dialog went away. The raise needs no timer, because a dialog is drawn at the END of a
|
||
* burst of output and the idle confirmation reads the screen once the pane goes quiet. The
|
||
* clear does: answering resumes the turn, and a running turn keeps the pane busy, so the
|
||
* settle path would not look again until the whole turn ended, and the red alert would
|
||
* outlive the dialog by minutes. One `capture-pane` per interval, and only while a dialog
|
||
* is up.
|
||
*/
|
||
export const BLOCKED_RECHECK_MS = 2000;
|
||
|
||
/**
|
||
* The longest a session showing a blocking dialog goes without re-reading a QUIET pane.
|
||
* A dialog leaves the screen only through a repaint, and every repaint reaches the
|
||
* session as PTY output, so while nothing has been drawn since the last reading that saw
|
||
* the dialog it is still up, and the BLOCKED_RECHECK_MS read is skipped. That read is a
|
||
* synchronous `tmux capture-pane`, about 8 ms of blocked event loop (measured 2026-10-14),
|
||
* and a dialog can sit unanswered overnight: one per 2 s per waiting session for hours.
|
||
* This bound is only the safety net under that reasoning, never the clear's latency.
|
||
*/
|
||
export const BLOCKED_QUIET_RECHECK_MS = 30_000;
|
||
|
||
/**
|
||
* Consecutive readable captures without the dialog before the blocked state clears. One
|
||
* read can land mid-repaint (a resize redraws the whole frame), and a clear followed by a
|
||
* re-raise would send the alert, the push and the webhook a second time for the same
|
||
* dialog. Two reads at BLOCKED_RECHECK_MS clear 2 to 4 s after the answer.
|
||
*/
|
||
export const BLOCKED_CLEAR_READS = 2;
|
||
|
||
/**
|
||
* Whether the foot of a pane shows a dialog this CLI blocks on (an approval, a question,
|
||
* a trust prompt), read with the CLI's `capabilities.workDetect.blockedLine`.
|
||
*
|
||
* The last `tailLines` non-blank rows are joined with `\n` and tested once, so the pattern
|
||
* can demand several markers and anchor its footer on the end of the window. Blank rows
|
||
* are dropped first, like `watchingLabel()`, because a TUI pads its dialog with them.
|
||
*
|
||
* ⚠️ The text is pane-derived and the agent writes most of the pane, so a pattern must
|
||
* end on chrome only the CLI draws (a dialog's key-hint footer replaces the composer and
|
||
* its footer, which nothing the agent prints can sit below). Even so, a match is only
|
||
* ever an ALERT: nothing keys a keystroke off it.
|
||
*
|
||
* @param tailLines how many non-blank rows from the bottom the pattern sees, defaulting to
|
||
* `BLOCKED_TAIL_LINES` and capped at `BLOCKED_MAX_TAIL_LINES`
|
||
*/
|
||
export function isBlockedScreen(
|
||
paneText: string | null | undefined,
|
||
pattern: RegExp,
|
||
tailLines: number = BLOCKED_TAIL_LINES
|
||
): boolean {
|
||
if (!paneText) return false;
|
||
const rows = stripAnsi(paneText)
|
||
.split('\n')
|
||
.map((row) => row.trimEnd())
|
||
.filter((row) => row !== '');
|
||
const window = rows.slice(-Math.max(1, Math.min(tailLines, BLOCKED_MAX_TAIL_LINES))).join('\n');
|
||
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
|
||
pattern.lastIndex = 0;
|
||
return pattern.test(window);
|
||
}
|
||
|
||
/**
|
||
* How many rows above the composer the turn's closing row may sit. Between the two Claude
|
||
* draws only its composer border and, sometimes, a right-aligned hint
|
||
* (`new task? /clear to save 169.1k tokens`), so this leaves room for a blank row or two
|
||
* and no more. A bound, not a tuning knob: the walk must never reach far enough up the
|
||
* transcript to find an old turn's row.
|
||
*/
|
||
export const AWAITING_SEARCH_ROWS = 6;
|
||
|
||
/** A row that opens with a box-drawing character is the composer's frame, not transcript. */
|
||
const COMPOSER_FRAME_ROW = /^[─-╿]/;
|
||
|
||
/**
|
||
* Whether the pane's newest turn ended by handing off to workers the CLI will wait for,
|
||
* e.g. Claude's `✻ Waiting for 1 dynamic workflow to finish`.
|
||
*
|
||
* Such a pane is quiet and shows its composer, so every other signal calls it idle, yet
|
||
* nothing is being asked of the user: the CLI resumes by itself when the workers report
|
||
* back. That is why a session in this state counts as working.
|
||
*
|
||
* ⚠️ The row is a snapshot. Claude renders it once, at the end of the turn, and never
|
||
* updates it, so after the workers finish the same words are still on screen above the
|
||
* follow-up turn. Matching them anywhere on the pane would pin the session busy for as
|
||
* long as they stay visible. Only the newest transcript row counts: the walk starts at
|
||
* the composer (the LAST row carrying `promptGlyph`), steps up past blank rows, the
|
||
* composer's frame and anything indented (a right-aligned hint, a wrapped continuation),
|
||
* and tests the first row that starts in column 0. A follow-up turn always puts rows of
|
||
* its own there, so the stale copy is never the one tested.
|
||
*
|
||
* @param promptGlyph the CLI's composer glyph (`capabilities.workDetect.promptGlyph`)
|
||
* @returns false when the screen shows no composer, which is no evidence either way
|
||
*/
|
||
export function isAwaitingWorkers(paneText: string | null | undefined, pattern: RegExp, promptGlyph: string): boolean {
|
||
if (!paneText) return false;
|
||
const rows = stripAnsi(paneText)
|
||
.split('\n')
|
||
.map((row) => row.trimEnd());
|
||
let composer = -1;
|
||
for (let i = rows.length - 1; i >= 0; i--) {
|
||
// Claude has drawn its composer both bare (`❯ …` between rules) and boxed (`│ ❯ … │`).
|
||
if (rows[i].replace(/^[\s│]+/, '').startsWith(promptGlyph)) {
|
||
composer = i;
|
||
break;
|
||
}
|
||
}
|
||
if (composer < 0) return false;
|
||
for (let i = composer - 1; i >= Math.max(0, composer - AWAITING_SEARCH_ROWS); i--) {
|
||
const row = rows[i];
|
||
if (row === '' || /^\s/.test(row) || COMPOSER_FRAME_ROW.test(row)) continue;
|
||
// Same reasoning as watchingLabel(): a caller's `g` flag must not make this flap.
|
||
pattern.lastIndex = 0;
|
||
return pattern.test(row);
|
||
}
|
||
return false;
|
||
}
|