mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
a8a8bb71a3
commit
beae377122
38 files changed
+2016
-5
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user