mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(codex): read Codex's own background-terminal row
Codex states background work too, and it says so in a different place. Claude writes `· 1 monitor ·` on the last row of the screen; Codex pins `1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which puts that row third from the bottom once the status line and the composer are counted. So how far up the screen to look is now per-CLI data as well: `capabilities.workDetect.watchingLines`, bounded to 1..8 by the schema, and defaulting to Claude's two. That bound is the point. The window is half the injection guard, since every row it adds is another row the agent itself may be able to write, and the label is what silences an idle alert. The other half is the anchor, and Codex's is ` · /ps to view`: chrome naming a slash command only the CLI can offer, so a session that writes "I left 1 background terminal running for you" into its own output matches nothing. Measured against a live codex-cli 0.154.0 pane rather than read out of a binary. The row appears when the terminal starts, follows the composer down as the conversation grows, and is gone after `/stop`. Verified end to end on an isolated beta: the session payload carried `watching: "1 background terminal"` and the badge rendered with it, and both cleared when the terminal stopped. The fixtures in the tests are that capture verbatim. Codex has no hook signals, so no idle prompt and no false NEEDS YOU row: for a Codex session this is the badge alone, which is the case the maintainer said a registry field could cover and a hook never could. Cross-CLI tests pin that neither pattern fires on the other's screen, and that a CLI declaring nothing still reports nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
74884a20eb
commit
64c288a683
@@ -4,7 +4,7 @@
|
||||
|
||||
A session watching work it started itself no longer asks you to look at it. An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn, so the pane goes quiet, Claude Code's idle notification lands a minute later, and the session shows up under NEEDS YOU with nothing for anyone to answer.
|
||||
|
||||
Claude prints what it is still running on the last row of its screen, and the CLI registry now carries that row as an optional `capabilities.workDetect.watchingLine`. The idle probe reads the label ("1 monitor", "2 shells") off the capture it already takes when a turn ends, and it reaches the session payload as `watching`.
|
||||
A CLI states what it is still running on its own screen, and the CLI registry now carries that row as an optional `capabilities.workDetect.watchingLine`. The idle probe reads the label off the capture it already takes when a turn ends, and it reaches the session payload as `watching`. Claude writes its chip on the last row (`· 1 monitor ·`, also shells, cloud sessions and background tasks); Codex pins `1 background terminal running · /ps to view` above its composer and declares the deeper window that needs. Both patterns were measured against live panes, and a CLI that declares none reports no background work, as every CLI did before.
|
||||
|
||||
The prompt that follows then opens already acknowledged. It stays in the Approvals drawer, stays answerable and stays Read My Mind context, and only the alert it would have armed is spent: no tab alert, no desktop notification, no push, and no NEEDS YOU row on any surface, `codeman tui` included. The card says why, reading "quiet, watching 1 monitor" rather than implying somebody looked. It re-arms by itself, because the next idle prompt supersedes this item and is built fresh. A permission or question dialog still goes red whatever else the agent started.
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. ⚠️ **A quiet pane is not always a pane that wants you.** An agent that arms a monitor, backgrounds a shell or hands work to a cloud session is told to end its turn, so the pane goes quiet, Claude Code's `idle_prompt` notification lands a minute later and every surface files the session under NEEDS YOU with nothing to answer. The CLI states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), which is the optional `capabilities.workDetect.watchingLine`: `_confirmIdle()`'s own capture feeds `watchingLabel()` (`session-activity.ts`, pure), and the label lands on `Session.watching`. ⚠️ **The fix is the alert that does not fire, not the badge.** `hook-event-routes` passes that label to `notePrompt()`, which opens the idle item ALREADY acknowledged (`acknowledgedAt` + `acknowledgedReason`), so the prompt stays pending, answerable and Read-My-Mind context while the alert it would have armed is spent — the same `acknowledge()` semantics a human viewing the session has always produced. The broadcast carries `acknowledgedReason` so a live page declines to arm (`_onHookIdlePrompt`), the push is skipped, a reloading page reads `acknowledgedAt` in `seedApprovals()`, and `classifySession()` ignores an acknowledged item so `codeman tui` agrees. It re-arms for free: the next idle prompt supersedes this item and is built fresh. ⚠️ Only `idle` is eligible, so a permission or question dialog still goes red whatever else the agent started. The badge is the cosmetic half, and it rides BESIDE the state pill on the surfaces that have one, never in place of it. ⚠️ The label is **pane-derived and therefore prompt-injectable**: the search is confined to the last `WATCHING_TAIL_LINES` lines and each CLI's pattern anchors on its footer's own separator, because an agent that got a bare `1 monitor` matched would silence its own alert by printing it. Tests: `test/session-watching.test.ts` and `test/watching-no-alert.test.ts` (the negative one).
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. ⚠️ **A quiet pane is not always a pane that wants you.** An agent that arms a monitor, backgrounds a shell or hands work to a cloud session is told to end its turn, so the pane goes quiet, Claude Code's `idle_prompt` notification lands a minute later and every surface files the session under NEEDS YOU with nothing to answer. The CLI states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), which is the optional `capabilities.workDetect.watchingLine`: `_confirmIdle()`'s own capture feeds `watchingLabel()` (`session-activity.ts`, pure), and the label lands on `Session.watching`. ⚠️ **The fix is the alert that does not fire, not the badge.** `hook-event-routes` passes that label to `notePrompt()`, which opens the idle item ALREADY acknowledged (`acknowledgedAt` + `acknowledgedReason`), so the prompt stays pending, answerable and Read-My-Mind context while the alert it would have armed is spent — the same `acknowledge()` semantics a human viewing the session has always produced. The broadcast carries `acknowledgedReason` so a live page declines to arm (`_onHookIdlePrompt`), the push is skipped, a reloading page reads `acknowledgedAt` in `seedApprovals()`, and `classifySession()` ignores an acknowledged item so `codeman tui` agrees. It re-arms for free: the next idle prompt supersedes this item and is built fresh. ⚠️ Only `idle` is eligible, so a permission or question dialog still goes red whatever else the agent started. The badge is the cosmetic half, and it rides BESIDE the state pill on the surfaces that have one, never in place of it. ⚠️ The label is **pane-derived and therefore prompt-injectable**: the search is confined to the last few non-blank rows (`WATCHING_TAIL_LINES`, or the CLI's own `watchingLines` — claude writes its chip on the last row, codex pins one above its composer) and each pattern anchors on chrome only that CLI draws, because an agent that got a bare `1 monitor` matched would silence its own alert by printing it. Tests: `test/session-watching.test.ts` and `test/watching-no-alert.test.ts` (the negative one).
|
||||
|
||||
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck. ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. ⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). ⚠️ It reads `capturePaneText()` and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago. ⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing).
|
||||
|
||||
|
||||
+18
-8
@@ -36,8 +36,8 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine? } — how this CLI's pane
|
||||
// shows work, and how it shows work it started in the background
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||
// this CLI's pane shows work, and how it shows work it started in the background
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -57,14 +57,24 @@ into `Session.watching`, and an idle prompt from such a session opens already ac
|
||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
|
||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||
chip on the last row of the screen, so it keeps the default window of `WATCHING_TAIL_LINES`
|
||||
rows and anchors on the `·` its footer joins items with. Codex pins
|
||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||
puts the row third from the bottom once the status line and the composer are counted, so its
|
||||
entry declares `watchingLines: 4` and anchors on the ` · /ps to view` tail. Both were
|
||||
measured against live panes rather than read out of a binary, which is the standard for
|
||||
adding a third.
|
||||
|
||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last
|
||||
`WATCHING_TAIL_LINES` rows, which is the part of the screen the CLI draws rather than the
|
||||
agent, and the pattern itself anchors on the separator that CLI's footer uses to join its
|
||||
items. Without both, an agent could silence its own idle alert by printing `· 1 monitor ·`
|
||||
into its output. The label is also ANSI-stripped and length-capped at the source, since it
|
||||
ends up on a badge and in an approval card.
|
||||
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
||||
non-blank rows, which is the part of the screen the CLI draws rather than the agent, and the
|
||||
pattern anchors on chrome only that CLI can produce. Without both, an agent could silence its
|
||||
own idle alert by printing the words into its output. Keep the window as small as the layout
|
||||
allows, since every row it adds is another row the agent may be able to write. The label is
|
||||
also ANSI-stripped and length-capped at the source, since it ends up on a badge and in an
|
||||
approval card.
|
||||
|
||||
### Three capabilities that must stay independent
|
||||
|
||||
|
||||
@@ -318,6 +318,9 @@ const capabilitiesSchema = z
|
||||
'watchingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||
// and every row it adds is one more row the agent itself may be able to write.
|
||||
watchingLines: z.number().int().min(1).max(8).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -530,7 +530,21 @@ const CODEX: CliEntry = {
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
// alone separates a running turn from an idle one.
|
||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
||||
// Codex pins a row of its own while a background terminal it started is still
|
||||
// running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike
|
||||
// Claude's footer chip that row sits ABOVE the composer, which puts it third from the
|
||||
// bottom once the status line and the composer are counted, hence `watchingLines`.
|
||||
// The ` · /ps to view` tail is the anchor: it is CLI chrome, it names a slash command
|
||||
// that only the CLI can offer, and without it a bare count in the transcript would do.
|
||||
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
|
||||
// the terminal starts, follows the composer down as the conversation grows, and is
|
||||
// gone after `/stop`.
|
||||
workDetect: {
|
||||
promptGlyph: '›',
|
||||
workingLine: '[Ee]sc to interrupt',
|
||||
watchingLine: String.raw`(\d+ background terminals?) running · /ps to view`,
|
||||
watchingLines: 4,
|
||||
},
|
||||
transcript: 'codex-rollout',
|
||||
altScreen: 'strip-full',
|
||||
echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' },
|
||||
|
||||
@@ -345,13 +345,23 @@ export interface CliCapabilities {
|
||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||
workingLine: string;
|
||||
/**
|
||||
* Source of a regex matching the chip this CLI draws at the foot of its screen while
|
||||
* work it started in the background is still running, e.g. Claude's `· 1 monitor ·`.
|
||||
* Capture group 1 is the label Codeman shows, and the whole match stands in when the
|
||||
* pattern declares no group. A CLI that omits this reports no background work, which
|
||||
* is what every CLI did before the field existed.
|
||||
* Source of a regex matching the row this CLI draws while work it started in the
|
||||
* background is still running, e.g. Claude's `· 1 monitor ·` footer chip or Codex's
|
||||
* `1 background terminal running · /ps to view`. Capture group 1 is the label Codeman
|
||||
* shows, and the whole match stands in when the pattern declares no group. A CLI that
|
||||
* omits this reports no background work, which is what every CLI did before the field
|
||||
* existed.
|
||||
*/
|
||||
watchingLine?: string;
|
||||
/**
|
||||
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
||||
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
|
||||
* its own above the composer, which puts it third from the bottom, so it declares
|
||||
* more. Keep each number as small as that CLI's layout allows: every extra row is
|
||||
* another row an agent might be able to write, and the label is what silences an
|
||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
watchingLines?: number;
|
||||
};
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
|
||||
+23
-14
@@ -95,18 +95,20 @@ export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: numb
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lines at the foot of a pane capture may hold the background-work chip.
|
||||
* 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`).
|
||||
*
|
||||
* 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
|
||||
* Claude Code draws its 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 rows is what
|
||||
* covers the chip wherever a trailing blank or a one-line notice pushes it up by one.
|
||||
* Codex pins its row above the composer instead and declares four.
|
||||
*
|
||||
* ⚠️ 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.
|
||||
* PANE-DERIVED, and everything on that screen above the CLI's own chrome is text the
|
||||
* agent wrote itself, so an agent that printed `· 1 monitor ·` into its output would
|
||||
* silence its own idle alert. Keep each CLI's window as small as its layout allows,
|
||||
* keep its pattern anchored on chrome only that CLI can draw, and never widen either to
|
||||
* a whole-pane search.
|
||||
*/
|
||||
export const WATCHING_TAIL_LINES = 2;
|
||||
|
||||
@@ -122,20 +124,27 @@ export const MAX_WATCHING_LABEL_CHARS = 40;
|
||||
* 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.
|
||||
* 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): string | null {
|
||||
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(-WATCHING_TAIL_LINES).reverse()) {
|
||||
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.
|
||||
|
||||
+3
-1
@@ -2760,7 +2760,9 @@ export class Session extends EventEmitter {
|
||||
// Called only from the probe, and only with what a capture returned: `null` is
|
||||
// "the screen could not be read", which is not evidence that nothing is running.
|
||||
if (!pattern || paneText === null) return;
|
||||
this._watching = watchingLabel(paneText, pattern);
|
||||
// How far up the screen this CLI's row can sit is its own business: Claude writes on
|
||||
// the last row, Codex pins one above its composer. Both stay at the foot.
|
||||
this._watching = watchingLabel(paneText, pattern, getCli(this.mode)?.capabilities.workDetect?.watchingLines);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,21 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
|
||||
});
|
||||
|
||||
it('bounds how far up the screen a config file may search', () => {
|
||||
// The window is the injection guard: every row it adds is another row the agent
|
||||
// itself may be able to write, and the label is what silences an idle alert.
|
||||
for (const lines of [0, 9, 2.5]) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLine: 'chip (\\d+)',
|
||||
watchingLines: lines,
|
||||
};
|
||||
}, 'a config file must not be able to widen the search to the whole pane');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts every shipped watchingLine', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.watchingLine;
|
||||
|
||||
@@ -22,8 +22,40 @@ import {
|
||||
IDLE_SILENCE_MS,
|
||||
} from '../src/session-activity.js';
|
||||
|
||||
/** The registry's own pattern for Claude, which is what every consumer runs. */
|
||||
/** The registry's own patterns, which are what every consumer runs. */
|
||||
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
|
||||
const CODEX_WATCHING = compileVersionRegex(getCli('codex')!.capabilities.workDetect!.watchingLine!)!;
|
||||
const CODEX_TAIL = getCli('codex')!.capabilities.workDetect!.watchingLines!;
|
||||
|
||||
/**
|
||||
* The foot of a Codex pane, verbatim (codex-cli 0.154.0, 2026-09-22). Codex does not
|
||||
* write on its last row: the status line is there, the composer above it, and the
|
||||
* background-terminal row above that, which is why codex declares its own window.
|
||||
*/
|
||||
const CODEX_STATUS =
|
||||
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left';
|
||||
const CODEX_WITH_TERMINAL = [
|
||||
'• OK',
|
||||
'',
|
||||
' 1 background terminal running · /ps to view · /stop to close',
|
||||
'',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
CODEX_STATUS,
|
||||
'',
|
||||
].join('\n');
|
||||
const CODEX_STOPPED = [
|
||||
'• OK',
|
||||
'',
|
||||
'• Stopping all background terminals.',
|
||||
'',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
CODEX_STATUS,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/** The bottom of a Claude pane: composer, the user's status line, the footer row. */
|
||||
function pane(footer: string, body = ''): string {
|
||||
@@ -72,10 +104,13 @@ function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex'
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
}
|
||||
|
||||
/** Codex's own composer repaint, the frame that arms its idle confirmation. */
|
||||
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
|
||||
|
||||
/** Run one turn and let it end, which is when the probe reads the screen. */
|
||||
function runAndSettle(session: Session): void {
|
||||
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, COMPOSER_REPAINT);
|
||||
feed(session, repaint);
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
@@ -205,22 +240,35 @@ describe('Session.watching', () => {
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('reports nothing for a CLI whose footer nobody has characterised', () => {
|
||||
it('reads Codex own row, three up from the bottom of its screen', () => {
|
||||
vi.useFakeTimers();
|
||||
expect(getCli('codex')?.capabilities.workDetect?.watchingLine).toBeUndefined();
|
||||
// Codex draws its own composer glyph, so this session settles the same way; what it
|
||||
// must not do is read Claude's footer on a screen that is not Claude's.
|
||||
const session = withFakePane(WITH_MONITOR, 'codex');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m');
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
const session = withFakePane(CODEX_WITH_TERMINAL, 'codex');
|
||||
runAndSettle(session, CODEX_COMPOSER_REPAINT);
|
||||
expect(session.status).toBe('idle');
|
||||
expect(session.watching).toBe('1 background terminal');
|
||||
});
|
||||
|
||||
it('reports nothing for a CLI whose screen nobody has characterised', () => {
|
||||
vi.useFakeTimers();
|
||||
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
|
||||
const session = withFakePane(WITH_MONITOR, 'gemini');
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('never reads another CLI screen', () => {
|
||||
vi.useFakeTimers();
|
||||
// Each pattern is anchored on chrome its own CLI draws, so neither can fire on the
|
||||
// other's pane. A shared fallback would have both reading a screen nobody measured.
|
||||
const codexOnClaudeScreen = withFakePane(WITH_MONITOR, 'codex');
|
||||
runAndSettle(codexOnClaudeScreen, CODEX_COMPOSER_REPAINT);
|
||||
expect(codexOnClaudeScreen.watching).toBeNull();
|
||||
|
||||
const claudeOnCodexScreen = withFakePane(CODEX_WITH_TERMINAL, 'claude');
|
||||
runAndSettle(claudeOnCodexScreen);
|
||||
expect(claudeOnCodexScreen.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('rides along on the payload every session surface reads', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_SHELL);
|
||||
@@ -230,6 +278,35 @@ describe('Session.watching', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the row Codex draws', () => {
|
||||
it('reads the label, and only while a terminal is running', () => {
|
||||
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
|
||||
expect(watchingLabel(CODEX_STOPPED, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts terminals', () => {
|
||||
const three = CODEX_WITH_TERMINAL.replace('1 background terminal running', '3 background terminals running');
|
||||
expect(watchingLabel(three, CODEX_WATCHING, CODEX_TAIL)).toBe('3 background terminals');
|
||||
});
|
||||
|
||||
it('needs the window Codex declares: its row is not the last one', () => {
|
||||
// Pins WHY `watchingLines` exists. Claude's default of two rows reaches the status
|
||||
// line and the composer, and Codex's row sits one further up.
|
||||
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, 2)).toBeNull();
|
||||
expect(CODEX_TAIL).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('refuses the same words in the transcript, which is the injection guard', () => {
|
||||
// ` · /ps to view` is chrome: only the CLI offers that slash command. Without the
|
||||
// anchor an agent could print the sentence and silence itself.
|
||||
const claim = CODEX_STOPPED.replace(
|
||||
'• Stopping all background terminals.',
|
||||
'• I left 1 background terminal running for you.'
|
||||
);
|
||||
expect(watchingLabel(claim, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the registry pattern Claude declares', () => {
|
||||
it('is one the config-regex guard accepts', () => {
|
||||
// Same guard as `workingLine`: ~/.codeman/clis.json can set this field, and the
|
||||
|
||||
Reference in New Issue
Block a user