Skip to content

feat(codex): raise codex approval, question and trust dialogs as BLOCKED - #624

Merged
Ark0N merged 6 commits into
masterfrom
feat/codex-blocked-detection
Oct 16, 2026
Merged

Ark0N merged 6 commits into
masterfrom
feat/codex-blocked-detection

Conversation

@Ark0N

@Ark0N Ark0N commented Oct 14, 2026

Copy link
Copy Markdown
Owner

Codeman shows a session as BLOCKED (a red tab, a desktop notification, web push, webhooks, the phone overview's NEEDS YOU row, an Approvals Inbox card and the blocked wait signal) when an agent stops to ask the user something. Until now that came only from Claude Code's hooks and the DeepSeek Harness status bridge. Codex has no hooks, yet it is launched with its approvals on by default. So a codex session waiting on "Would you like to run the following command?" looked plainly idle, and one waiting on a question looked WORKING: the question's key hints contain esc to interrupt, which is codex's working line. This PR reads codex's dialogs off its screen and raises exactly what a Claude permission_prompt hook raises, as an alert only. Codeman never answers a codex dialog. It does not touch Claude or DeepSeek behavior, the inbox's answer flow, or any other CLI. Codex's stop signal stays a 400.

How it works

  • Registry data, no id branch. A new optional field capabilities.workDetect.blockedLine (a regex), plus blockedLines (window size, 1 to 24). It is validated through compileVersionRegex() in the schema refine and again when first used. Only codex declares one. Its pattern needs a › N. option row followed by the dialog's own key-hint row as the LAST row of the window (allowing for that row to wrap at narrow widths). It was measured on live codex-cli 0.162.0 at widths 200 to 36, and 31 sanitized captures are checked in as fixtures.
  • Session state. isBlockedScreen() (pure, src/session-activity.ts) is tested against the capture the idle confirmation already takes. The state raises on one reading and emits blockedChanged. A blocked screen wins over the working line in the same capture. While a dialog is up a timer re-reads every 2 s, but skips the read while the pane has drawn nothing since the last reading that saw the dialog. A dialog can only leave through a repaint, and every repaint reaches the session as output. A safety-net read still runs every 30 s. The state clears after 2 readable captures in a row without the dialog, and an unreadable capture changes nothing.
  • Server. src/web/screen-blocked.ts turns the edge into the hook route's outputs: notifySignal('blocked'), an answerInTerminal inbox item with the frame as context, hook:permission_prompt with source: 'screen', push and webhook (no approvalId, so no Approve/Deny), and a run-summary row. The clear resolves only the item it opened. The message every surface shows is built from the registry label, never from screen text.
  • Waits. blockedSignalAvailable() (hooks OR blockedLine) lets codex accept until=blocked. Because a screen-read dialog is a level, GET .../wait and a duplicate send-and-wait answer blocked at once for a caller that asked for it. A caller that asked only for idle still gets idle.

Testing

  • Unit and route tests: fixture matching (17 dialogs, 14 negatives including a transcript quoting the dialog and the composer holding its words), session state transitions, the quiet-pane skip, server raise and clear, wait routes, the approval answer route, the push body, and the registry schema.
  • Full npm test gate: 569 files passed, 1 skipped; 11,328 tests passed, 12 skipped. Typecheck, lint, format, the CLI catalogue check and the plugin mirror check are clean.
  • Live end-to-end on an isolated beta instance with real codex-cli 0.162.0 (approvals forced on, only a scratch repo trusted, ~/.codex/config.toml hash unchanged):
    • Command approval: wait?until=blocked resolved in 7.2 s, and the tab turned red in the browser. Answering y cleared it about 3 s later, the file was created, and nothing re-fired.
    • Two approvals back to back: each raised its own alert and cleared 2.1 s and 2.9 s after its answer. A live page showed red, clear, red, clear.
    • Question dialog (Plan mode): the session read idle, not working, while the question was up, and it cleared 2.1 s after Enter.
    • Trust prompt in an untrusted repository: blocked after 4.5 s. Deleting the session resolved the alert as session_ended.
    • Left unanswered for 80 s: 4 screen reads (2 caused by a browser resize, then one per 30 s), instead of about 40.

Reviewing the diff

About 2,900 added lines, most of them mechanical:

  • Fixtures test/fixtures/codex-dialogs/*.txt: about 1,300 lines of captured screens.
  • Docs: about 330 lines (docs/, docs/wiki/, both READMEs, CLAUDE.md, the skill reference and its plugin mirror, which scripts/sync-plugin.mjs generates).
  • Tests: about 760 lines.
  • i18n and frontend: 9 lines (one zh-CN pattern, the notification text).

About 300 lines of logic need real review. Start with isBlockedScreen() and the constants in src/session-activity.ts, then the _probePaneWorking, _noteBlockedReading, _armBlockedRecheck and _clearScreenBlocked block in src/session.ts. Then read src/web/screen-blocked.ts and its wiring in src/web/session-listener-wiring.ts, the wait changes in src/web/session-wait-registry.ts and src/web/routes/session-routes.ts, and the reordered guard in src/web/routes/approval-routes.ts. The codex regex sits in src/config/cli-registry/stock.ts, under a comment that records what was measured.

Risk

  • Pane text drives an alert. The agent writes most of the screen, so the pattern must end on chrome only codex draws at the foot of the pane. A forged or accidental match costs a false alert and nothing else: the item is alert-only, and no keystroke is ever derived from it. The one known residual is the user typing the dialog's exact hint text into their own composer under a lowercase model name.
  • Raise on one read, clear on two. That choice trades 2 to 4 s of alert after an answer for never double-pushing on a mid-repaint capture. The quiet-pane skip relies on every screen change reaching the session as output, with the 30 s safety-net read as a backstop.

Follow-ups

  • Answering codex dialogs from the Approvals Inbox: measure codex's keystroke contract first.
  • codeman tui should hide answer keys for alert-only items (same today for DeepSeek).
  • Two dialogs inside the 2 to 4 s clear window share one alert and one push. A Codeman restart with a dialog up pushes again.
  • Not yet measured, so likely not matched: MCP elicitation dialogs, the experimental network proxy approval, and Implement this plan?, which is excluded on purpose because its footer matches /model.
  • Remote SSH and Docker codex sessions were not tested end to end.

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>
A codex dialog the session now reads off its screen gets exactly what a
Claude `permission_prompt` hook gets, so every surface that already handles
that event needs no second path: `web/screen-blocked.ts` turns the session's
`blockedChanged` edge into the `blocked` wait signal, an Approvals Inbox
item, the `hook:permission_prompt` broadcast (with `source: 'screen'`), web
push and webhooks, and a run-summary row. That lights the red "needs you"
tab alert and desktop notification, the phone overview's NEEDS YOU row and
the TUI. When the dialog leaves the screen the item it opened is resolved,
and `approval:resolved` clears every client.

It is an ALERT and never a keystroke, following #610's shape for DeepSeek:
the item is `answerInTerminal`, push carries no `approvalId` (so no
Approve/Deny), and the answer route refuses it. The dialog was read from a
screen the agent writes most of, so a forged match must cost a false alert
and nothing more. The message every surface shows is built from the CLI's
registry label, never from the dialog's text. The item gets no `capture`
callback, only its frame as context, because the inbox's option parser and
staleness sweep are shaped for Claude's dialogs and the session owns this
item's lifetime. A CLI whose hooks report blocked gets nothing from the
screen, so declaring both could never alert twice.

The answer route now checks `answerInTerminal` BEFORE its hooks gate and
names the CLI: a codex session has no hooks, and "Session mode cannot have
pending approvals" would be false about the item the caller holds.

Wait signals: `blocked` is available where hooks report it OR a CLI declares
`blockedLine` (`blockedSignalAvailable()`); `stop` stays hooks-only, since
nothing on a screen says a turn ended, so `until=stop` on codex is still a
400 (now suggesting `idle, blocked or exit`). A screen-read dialog is a
level the server keeps reading, so a `GET .../wait` that asks for `blocked`
while one is up answers at once; a caller that asked for `idle` alone keeps
getting `idle`. The MCP and `codeman agent` help text say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Document what the two previous commits changed for users and for whoever
adds the field to another CLI:

- docs/cli-registry.md: the `blockedLine`/`blockedLines` fields, how codex's
  pattern was measured, and the three rules any declaring CLI keeps (end on
  chrome only the CLI draws, alert only; raise on one reading, clear on two;
  hooks win).
- docs/architecture-invariants.md: a "Screen-read blocked dialogs (codex)"
  entry, plus the wait-signal, idle-detection, watching and Approvals Inbox
  entries it changes.
- docs/api-reference.md, docs/extending-codeman.md and the wiki (Agent CLIs,
  Notifications and Approvals, Hooks, HTTP API, Driving Codeman From An
  Agent): `blocked` now fires for codex, `stop` still does not, codex
  approvals are alert-only cards answered in the terminal.
- CLAUDE.md: `stop` and `blocked` are no longer one rule, the config-regex
  list gains `blockedLine`, and a short pointer that a codex dialog match is
  only ever an alert.
- The codeman skill's signal table and verbs note, mirrored into
  plugins/codeman by scripts/sync-plugin.mjs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
`GET .../wait` answers `blocked` at once for a codex pane showing a dialog,
because a screen-read dialog is a level the server is still reading. The
duplicate branch of `POST .../input` (a tagged retry after a dropped
connection, answered from current state) still used the plain
`currentSignalFor()`, so a retry with `wait: "blocked"` on a pane the first
delivery had already blocked waited out its whole timeout for an edge that
had passed. It now uses `currentSignalForWait()` like the GET route; a
caller that asked only for `idle` still gets `idle`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…blocks

The README (both languages) still said only claude and deepseek sessions
emit `blocked` and that codex accepts only idle, working and exit, and the
skill's Flow 5 called `blocked` claude-mode only. Codex now raises it from
its screen-read dialogs; the recipe says to watch it with
`until=blocked,exit` (codex has no `stop`) and to answer in the terminal.
The plugin mirror is regenerated with scripts/sync-plugin.mjs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N
Ark0N merged commit 71a9cc7 into master Oct 16, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 16, 2026
On codex-cli 0.162.0, with 0.163.0 released, every launch first shows the
"Update available" prompt, and choosing Skip leads to a variant of the
workspace trust prompt: "2. Back to Agent Command Center" over the key-hint
row "enter continue · esc back". The pattern only accepted cancel,
interrupt, quit, answer or all as the closing word, so a fresh git repo
launched from Codeman sat on that trust prompt reading idle, with no red
alert and no blocked signal (measured live on a throwaway instance).

Adding "back" is safe because the hint row must still START with one of
the anchored hints ("enter continue" here): the popups the user opens at
the keyboard (/model, /permissions, /review, Implement this plan?) end in
"esc back" too, but their row starts with "enter select", so they stay
unmatched, as every existing negative fixture confirms. The new fixture is
the live capture with the scratch path replaced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 16, 2026
feat(codex): raise codex approval, question and trust dialogs as BLOCKED

Conflict resolved against #634 in src/session.ts _probePaneWorking():
#624's assignment (a dialog on screen is never work) is kept, and #634's
trust-dialog verdict block (log the hold once, remember the reading)
follows it, before _readWatching().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 16, 2026
One release changeset for #616, #617, #621, #622, #623, #624, #625, #628, #632, #634, #635 and #642. The changesets carried by #616, #617 and #642 are folded into it and removed (#617's was minor; 1.41.2 is a patch release).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants