feat(codex): read approval, question and trust dialogs off the screen

Codex is the one CLI Codeman launches with its approvals on by default, and
it declares `hooks: 'none'`, so nothing tells Codeman it stopped to ask. A
codex session parked on "Would you like to run the following command?" read
as plainly idle, and one parked on a question read as WORKING, because the
question's own key hints end in `esc to interrupt`, codex's working line.

This adds a generic, optional registry field,
`capabilities.workDetect.blockedLine` (plus `blockedLines`, the window), and
the session state that reads it:

- `isBlockedScreen()` (session-activity.ts, pure) joins the last
  `blockedLines` non-blank rows of a capture and tests the pattern once, so a
  pattern can demand several markers and anchor the dialog's footer on the
  end of the window.
- `Session` reads it off the same capture the idle confirmation already
  takes, emits `blockedChanged`, and exposes `screenBlocked`. It raises on
  one reading, and while a dialog is up re-reads the screen every 2 s,
  because answering resumes the turn and a busy pane would not reach the
  settle path again until the turn ended. It clears only after two readable
  captures in a row without the dialog, so a capture taken mid-repaint cannot
  clear and re-raise it; an unreadable capture changes nothing. A blocked
  screen wins over the working line in the same capture.
- The pattern goes through `compileVersionRegex()` in both the schema refine
  and at use (lazily cached), like every other config regex.

Codex's pattern was measured on a live codex-cli 0.162.0 pane on 2026-10-14
(`-a on-request -s read-only`, widths 200 down to 36): it needs a `› N.`
option row and then the dialog's key-hint row as the last row, allowing for
that row to wrap below about 60 columns. Command, edit and network-escalation
approvals, single and multi-question dialogs and the startup trust prompt
match; the idle and working views, menus the user opened (`/model`,
`/permissions`, `/review`), the composer holding the dialogs' words and a
transcript quoting them verbatim do not. `Implement this plan?` is left out
on purpose: its footer is the same as `/model`'s and the turn has ended
there. The captures are kept as fixtures (trailing whitespace trimmed, the
scratch path replaced).

Nothing consumes `blockedChanged` yet; the next commit raises the alert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-14 05:18:24 +02:00
1 parent a8a8bb71a3
commit beae377122
38 files changed
+2016 -5

No files matched your search

+60
View File
@@ -159,6 +159,66 @@ export function watchingLabel(
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;
/**
* 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