Compare commits

...
Author SHA1 Message Date
Codeman maintainer 71a9cc74c1 docs(codex): README and the skill's blocked-worker recipe know codex 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>
2026-10-14 05:53:58 +02:00
Codeman maintainer 3ae6cde2d6 fix(wait): a retried send-and-wait finds a screen-read dialog
`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>
2026-10-14 05:53:58 +02:00
Codeman maintainer 1c1658d039 fix(codex): skip the blocked re-read while the pane is quiet
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>
2026-10-14 05:53:58 +02:00
Codeman maintainer 9b3030f9a1 docs(codex): blocked dialogs read off the screen
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>
2026-10-14 05:19:14 +02:00
Codeman maintainer a35ddd82ba feat(codex): raise a screen-read dialog as BLOCKED, alert-only
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>
2026-10-14 05:18:37 +02:00
Codeman maintainer beae377122 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>
2026-10-14 05:18:24 +02:00
74 changed files with 2903 additions and 112 deletions
+5 -3
View File
@@ -126,7 +126,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md`
@@ -142,6 +142,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line)
⚠️ **A codex dialog is BLOCKED, read off the screen, and only ever an ALERT.** Codex has no hook for its approval, question and trust dialogs, so `capabilities.workDetect.blockedLine` names their chrome and `web/screen-blocked.ts` raises what a `permission_prompt` hook raises, as an `answerInTerminal` item. ⚠️ Never key a keystroke off that match (the agent writes most of the pane), and never hand its item a `capture` (the inbox's sweep is Claude-shaped; the session's 2 s re-read owns the clear). A blocked screen beats the working line. → [architecture-invariants#screen-read-blocked-dialogs-codex](docs/architecture-invariants.md#screen-read-blocked-dialogs-codex)
⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`.
**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit)
@@ -168,7 +170,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
@@ -192,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore)
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory, claude-only queue fed by `/api/hook-event`, at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory queue fed by `/api/hook-event` (answerable items claude-only; DeepSeek's and codex's screen-read ones are alert-only `answerInTerminal`), at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox)
**Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles)
+1 -1
View File
@@ -847,7 +847,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
5. **`/api/v1/*`** is a stable alias of `/api/*`.
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
7. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Those two come from hooks (Claude Code's own, and the DeepSeek Harness status bridge); `shell` and the other external CLIs (opencode/codex/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
7. **Only `claude` and `deepseek` sessions emit `stop`; `codex` emits `blocked` too.** Those come from hooks (Claude Code's own, and the DeepSeek Harness status bridge), except codex's `blocked`, which Codeman reads off its screen while an approval, question or trust dialog is up (answer it in the terminal). `shell` and the other external CLIs (opencode/gemini/antigravity/pi/grok/omp) accept only `idle`, `working` and `exit`, and codex adds `blocked`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
### Recipes
+1 -1
View File
@@ -849,7 +849,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失
4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。
5. **`/api/v1/*`** 是 `/api/*` 的稳定别名。
6. **用等待代替轮询,别把超时当成错误。** 等待类端点在没等到事情发生时也以 HTTP `200` 加 `wait.timedOut: true` 应答,所以要循环调用短等待(默认 60 秒),而不是发一个超长的调用:隧道会掐断空闲连接。`wait.timeoutMs` 告诉你服务端钳制之后真正采用的超时(上限 600 秒)。
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop` 与 `blocked`。** 这两个来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接);`shell` 与其他外部 CLI(opencode/codex/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
7. **只有 `claude` 与 `deepseek` 会话会发出 `stop`;`codex` 还会发出 `blocked`。** 它们来自 hook(Claude Code 自己的,以及 DeepSeek Harness 的状态桥接),codex 的 `blocked` 除外:批准、提问或信任对话框出现时,Codeman 从它的屏幕上读出这个信号(请在终端里回答)。`shell` 与其他外部 CLI(opencode/gemini/antigravity/pi/grok/omp)只接受 `idle`、`working` 与 `exit`,codex 另外接受 `blocked`。在这些模式上显式索要 `stop` 会得到 `400`;不传 `until` 则永远安全。⚠️ `shell` 会话的 `idle` 只在启动时触发**一次**,此后再也不会,所以在那里用「发送并等待」只能等到超时:没有 hook 的会话请用 `wait-output` 标记来同步。
8. **没有任何东西会报告「就绪」,得自己显式等。** 新会话在 PID 出现之前一律回答 `{"signal":"exit","immediate":true}`(意思是*还没启动*,不是*崩了*),而全新 case 里的 `claude` 工作会话接着会停在 CLI 的信任对话框上。此时给它发提示,等待会在约 2 秒后因 `idle` 解除,看上去和一个跑完的回合一模一样,而文本其实卡在对话框里。下面的配方 2b 就是避开它的顺序。
### 常用配方
+34 -17
View File
@@ -205,15 +205,17 @@ auto-retried by several clients (silently doubling the polling load), `504` is w
a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status` /
`limitPaused`. Reserve error handling for the four codes in the table below.
**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude
Code hooks, and no other mode installs them: `shell` runs no agent, and the external
CLIs (`opencode`, `codex`, `gemini`, `antigravity`, `pi`) render their own TUIs and post
no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are
accepted, and of those only `exit` is dependable: see the caveats under
[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked`
**explicitly** on such a session is a
`400`; omitting `until` never fails, the server just drops them from the default set
and echoes the narrowed set back as `wait.until`. Three more places hooks can go
**2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for
`codex`.** Both come from Claude Code hooks, and no other mode installs them: `shell`
runs no agent, and the external CLIs render their own TUIs and post no hooks
(`deepseek`'s status bridge reports the same two, see its own docs). The one exception
is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off
its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode
only `idle`, `working` and `exit` are accepted, and of those only `exit` is
dependable: see the caveats under [Signals](#signals) before building on `idle`.
Requesting a signal the session can never emit **explicitly** is a `400` (`stop` on
`codex` included); omitting `until` never fails, the server just drops them from the
default set and echoes the narrowed set back as `wait.until`. Three more places hooks can go
missing even in `claude` mode: a **Docker case** needs
`CODEMAN_DOCKER_BRIDGE_HOOKS=1`, since a container cannot reach a loopback-bound
Codeman (without it, only `idle` / `working` / `exit` work); a **remote-SSH
@@ -239,8 +241,8 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
|--------|--------|--------------------|
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `shell`: **once only**, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization) |
| `working` | the session's own `working` event | `claude` only in practice (spinner and work-keyword detection are Claude output formats) |
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` only |
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook | `claude` only, and rarer than it looks: see below |
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only |
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below |
| `exit` | no process is behind the session | every mode |
`stop` is the signal to orchestrate on where it exists; `idle` is a heuristic
@@ -277,6 +279,17 @@ asking the user a question. So `until=stop,blocked,exit` is a reasonable belt on
long turn, but a worker that never comes back is far more likely to be working than
blocked, and polling `blocked` alone will sit at its timeout.
**`blocked` on `codex` is read off the screen**, because codex has no hooks and is
launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set.
A codex pane showing a command, edit or network approval, a question (`request_user_input`)
or the startup trust prompt raises `blocked` (plus the red alert, push and webhook a Claude
`permission_prompt` raises, alert-only: answer it in the terminal), and the session reads
`idle` while it is up. Unlike a hook, this is a level the server keeps reading, so a wait
for `blocked` that arrives while the dialog is already up answers at once
(`immediate: true`); a wait that did not ask for `blocked` is unaffected. It clears 2 to 4 s
after the dialog leaves the screen. `stop` stays a `400` on codex: nothing on a screen says a
turn ended.
⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell
session emits its one `idle` at startup and then stays `status: "idle"` forever,
whatever the pane is doing, so it never emits a *transition*. Since send-and-wait
@@ -520,7 +533,7 @@ read the timeout as "the worker is wedged" and kill a session that was working f
| `errorCode` | HTTP | When |
|-------------|------|------|
| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that installs no hooks (the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` |
| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` |
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
| `SESSION_BUSY` | 409 | this session's waiter cap is full |
| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem |
@@ -744,15 +757,18 @@ non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged.
## Approvals Inbox
Cross-session queue of prompts waiting on a human (permission dialogs,
AskUserQuestion questions, idle prompts). Claude-mode sessions only; items are
in-memory (a server restart drops them; the next prompt re-fires the hook).
AskUserQuestion questions, idle prompts). Answerable items come from Claude-mode
sessions only; DeepSeek Harness (its status bridge) and Codex (dialogs read off its
screen) open **alert-only** items with `answerInTerminal: true`. Items are
in-memory (a server restart drops them; the next prompt re-fires the hook, and a
codex dialog still on screen is read again after the pane re-attaches).
Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
- `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first,
ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId,
sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?,
toolSummary?, message?, cwd?, context?, options?: {n, label}[],
acknowledgedAt? }`. `context` is the ANSI-stripped visible pane frame;
acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame;
`options` is present only when the dialog's numbered choices parsed
confidently; `acknowledgedAt` marks an item a human has already looked at
(see `/viewed` below) and tells clients not to re-arm its tab alert. Listing
@@ -766,8 +782,9 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
(sends the digit; accepted only when `n` is among the item's parsed
`options`), or `{ action: 'text', text }` (idle prompts only; submits the
line as a prompt). `404 NOT_FOUND` when the item is no longer pending,
`409 CONFLICT` when the dialog left the screen or another actor answered
first, `422 OPERATION_FAILED` when the session refused input.
`400 INVALID_INPUT` for an `answerInTerminal` item (`<CLI> approvals must be
answered in the terminal`), `409 CONFLICT` when the dialog left the screen or
another actor answered first, `422 OPERATION_FAILED` when the session refused input.
- `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes.
- `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId,
acknowledged: itemId | null }`. Marks the session's pending **idle** item as
File diff suppressed because one or more lines are too long
+63 -7
View File
@@ -46,9 +46,10 @@ 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?, watchingLines?, awaitingLine? }
// — how this CLI's pane shows work, work it started in the background, and a turn
// that ended waiting for workers it will resume from
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?,
// blockedLine?, blockedLines? }
// (how this CLI's pane shows work, work it started in the background, a turn
// that ended waiting for workers it will resume from, and a dialog it is blocked on)
// .modelDetect?: { screenLine, screenLines? }
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
@@ -62,7 +63,7 @@ interface CliEntry {
### Regexes that come from config
Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
@@ -118,9 +119,64 @@ nothing the agent can write — not even the status line above it, whose command
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
shares its slot with the last row of the transcript whenever no terminal is running, so a
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
sure its row is one the agent cannot write.
IDLE prompt from a codex session ever reaches the approvals inbox (its screen-read dialogs,
below, open PERMISSION items, which a watching label never acknowledges), so a forged label
costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a
pattern, make sure its row is one the agent cannot write.
### Dialogs a CLI blocks on (`blockedLine`)
`blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to
report it. Codex is the reason it exists: it is the one CLI Codeman launches with its
approvals on by default, and with `hooks: 'none'` a codex session parked on "Would you like to
run the following command?" used to read as plainly idle, and one parked on a question as
WORKING (the question's own key hints end in `esc to interrupt`, codex's working line). A
pane matching it is BLOCKED, which raises exactly what a Claude `permission_prompt` hook
raises: the red "needs you" tab alert and desktop notification, web push and webhooks, the
phone overview's NEEDS YOU row, an Approvals Inbox item, a run-summary row and the `blocked`
wait signal (`web/screen-blocked.ts`). `stop` stays unavailable for such a CLI: nothing on a
screen says a turn ended.
The pattern runs over the last `blockedLines` (1 to 24, default 1) non-blank rows, joined
with newlines and tested once (`isBlockedScreen()`, `session-activity.ts`), so it can demand
several markers and anchor the dialog's footer on the END of the window (no `m` flag: `$` is
the last row). Codex's needs a `› N.` option row and then its key-hint row (`Press enter to
confirm or esc to cancel`, `tab to add notes | enter to submit answer | esc to interrupt`,
`enter continue · esc quit` for the startup trust prompt), allowing for that row to wrap
onto rows of its own below about 60 columns. Measured on codex-cli 0.162.0 at widths 200 down
to 36: the command, edit and network-escalation approvals, single and multi-question
`request_user_input` dialogs (notes field open or closed), and the workspace trust prompt
match; the idle and working views, popups the user opened (`/model`, `/permissions`,
`/review`), the composer holding the dialogs' words and a transcript quoting them verbatim
do not. `Implement this plan?` after a plan turn is deliberately not matched: its footer is
the same as `/model`'s, and the turn has ended there. The fixtures are in
`test/fixtures/codex-dialogs/`.
Three rules hold for any CLI that declares one:
- ⚠️ The text is pane-derived and the agent writes most of the pane, so the pattern must end
on chrome only the CLI draws, at the foot of the screen, where a dialog replaces the
composer. A match is an ALERT and nothing else: the inbox item is `answerInTerminal`, the
answer route refuses it, push offers no Approve/Deny, and Codeman never answers such a
dialog itself, so a forged match costs a false alert and never a keystroke.
- The state raises on one reading (the session's settle probe, which reads the screen once
the pane goes quiet, which is when a dialog has just been drawn), and while it is up a timer
re-reads the screen every 2 s (`BLOCKED_RECHECK_MS`), because answering resumes the turn and
a busy pane would not reach the settle path again until the turn ended. The read is skipped
while the pane has drawn nothing since the last reading that saw the dialog (a dialog leaves
only through a repaint), with a safety-net read every 30 s (`BLOCKED_QUIET_RECHECK_MS`), so a
dialog left unanswered for hours does not cost a synchronous `tmux capture-pane` every 2 s.
It clears only after
two readable captures in a row without the dialog (`BLOCKED_CLEAR_READS`), so a capture
taken mid-repaint cannot clear and re-raise it (a second alert, push and webhook for one
dialog). An unreadable capture changes nothing in either direction. A blocked screen wins
over the working line in the same capture.
- Hooks win: a session whose CLI reports blocked itself (`hooksAvailableForMode()`) gets
nothing from the screen, so declaring both can never alert twice for one dialog.
A `GET /api/sessions/:id/wait?until=blocked` that arrives while such a dialog is already up is
answered at once (`immediate: true`): unlike a hook's edge, this is a level the server is
still reading. A caller that asked only for `idle` keeps getting `idle`.
### Three capabilities that must stay independent
+8 -5
View File
@@ -220,8 +220,9 @@ whether your integration works, and the last one is what actually bites:
to die in the field.
- **`wait.timeoutMs`** is the timeout after server-side clamping (600 s ceiling by
default). Read it rather than assuming you got what you asked for.
- **`stop` and `blocked` only exist for `claude` sessions**, and on a `shell` session
even `idle` fires only once at startup, so send-and-wait there can only time out.
- **`stop` only exists for `claude` (and `deepseek`) sessions, `blocked` also for
`codex`** (read off its screen), and on a `shell` session even `idle` fires only once
at startup, so send-and-wait there can only time out.
See the Gotchas below.
⚠️ **There is no readiness signal, and skipping readiness is the failure that looks
@@ -438,9 +439,11 @@ Every one of these has cost somebody real time.
a multi-word match is unreliable there. Match one short space-free token, ideally
one you printed yourself, and keep it out of the typed line (your own keystrokes
echo into the stream).
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini`,
`antigravity` or `pi` sessions.** They come from Claude Code hooks, which no other mode
installs, so only `idle`, `working` and `exit` exist there. Asking for them
- **`stop` and `blocked` never fire for `shell`, `opencode`, `gemini`, `antigravity` or
`pi` sessions, and `stop` never for `codex`.** They come from Claude Code hooks, which
no other mode installs, so only `idle`, `working` and `exit` exist there. The exception
is `blocked` on `codex`, which Codeman raises from the approval, question and trust
dialogs it reads off codex's screen. Asking for them
explicitly is a `400`; omitting `until` is safe, since the server drops them from
the default set and echoes what it actually waited on as `wait.until`. Even in
`claude` mode, a Docker case needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for hooks to
+13 -2
View File
@@ -72,12 +72,12 @@ output. The other CLIs expose no equivalent.
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek yes; others no |
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
| Read My Mind | Yes | No |
| Ralph loop and its task tracker | Yes | No |
| Subagent and team windows | Yes | No |
| Model, effort, advisor, and ultracode controls | Yes | No |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly |
| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly |
| The bundled agent skill | Yes | No |
Everything that makes a session a session works everywhere. What is Claude-only is mostly
@@ -153,6 +153,17 @@ Two behaviours that are deliberate and worth knowing:
does; before 1.26.1 every Codex session reported idle for its whole life. Codex
conversations also appear in Past Sessions and can be resumed, and on phones the keyboard
bar grows `⇧←` / `⇧→` for Codex's queued-message editing and prompt stack.
- **A dialog waiting for you turns the tab red.** Codex is launched with its approvals on
unless you tick the bypass option, and it has no hooks to say it stopped, so Codeman reads
its dialogs off the screen: a command, edit or network approval, a question it asks you
(Plan mode), and the "Trust this folder?" prompt when it starts in a new folder. Each one
raises the same red "needs you" alert a Claude permission prompt does, with the desktop
notification, web push, webhook, the phone overview's NEEDS YOU row and an Approvals Inbox
card, and the alert clears a few seconds after you answer. Answer in the terminal: Codeman
never answers a Codex dialog for you, so the inbox card and the push carry no Approve or
Deny buttons. While such a dialog is up the session shows as idle (a question used to show
as working), and agents can wait on it with the `blocked` signal. "Implement this plan?"
at the end of a plan is not counted, since the turn has ended there.
### Gemini
+7 -4
View File
@@ -180,10 +180,13 @@ Read these before writing any code. Each one has cost somebody an afternoon.
5. **Wait instead of polling, and a timeout is not an error.** The wait endpoints answer
`200` with `wait.timedOut: true`. Loop over short waits rather than one long call, because
tunnels cut idle connections.
6. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Claude's come from
Claude Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and
the other external CLIs accept only `idle`, `working`, and `exit`; asking for `stop`
explicitly there is a `400`, while omitting `until` is always safe. On a shell session
6. **Only `claude` and `deepseek` sessions emit `stop`; `blocked` also fires for `codex`.**
Claude's come from Claude Code hooks, DeepSeek's from the harness reporting its state to
Codeman, and Codex's `blocked` from Codeman reading its approval, question and trust
dialogs off the screen (a wait that asks for `blocked` while such a dialog is already up
answers at once). Shell and the other external CLIs accept only `idle`, `working`, and
`exit`; asking for `stop` explicitly there (codex included) is a `400`, while omitting
`until` is always safe. On a shell session
`idle` fires **once at startup and never again**, so synchronize hook-less sessions with an
output marker instead.
7. **Nothing reports "ready", so wait for it explicitly.** A new session answers
+4 -3
View File
@@ -142,9 +142,10 @@ Three semantics that break callers who assume otherwise:
`wait-output` matches a **literal substring, never a regex.** That is deliberate: no regex
means no catastrophic backtracking on attacker-influenced output.
Only `claude` and `deepseek` sessions emit `stop` and `blocked`: Claude's come from Claude
Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and the other
external CLI sessions accept `idle`, `working`, and `exit`.
Only `claude` and `deepseek` sessions emit `stop`: Claude's come from Claude Code hooks,
DeepSeek's from the harness reporting its state to Codeman. `blocked` fires for those two
and for `codex`, whose approval, question and trust dialogs Codeman reads off the screen.
Shell and the other external CLI sessions accept `idle`, `working`, and `exit`.
## SSE
+5 -2
View File
@@ -22,8 +22,11 @@ terminal into something that can notify you.
This is why several Codeman features are Claude-only. The one partial exception is DeepSeek
Harness, whose terminal front door reports idle, working and blocked to Codeman over the
harness's own supervisor contract, so it gets the hook-driven surfaces without any hook
file. The other CLIs have no equivalent, so for them Codeman watches terminal output, which
reveals that something happened but not what it was.
file. Codex gets one of them from its screen: Codeman reads its approval, question and
trust dialogs off the pane and raises the same red "needs you" alert a `permission_prompt`
hook raises (see [Agent CLIs](Agent-CLIs#codex)). The other CLIs have no equivalent, so for
them Codeman watches terminal output, which reveals that something happened but not what it
was.
### How hooks get installed
+10 -4
View File
@@ -34,8 +34,11 @@ reloading the dashboard while a permission dialog is blocking a session does not
with a normal-looking tab.
For Claude sessions, these come from Claude Code's hooks and are precise about *why* the
session stopped; DeepSeek Harness sessions report the same states themselves. For the other
CLIs there are no hooks, so you get the coarser output-based signal.
session stopped; DeepSeek Harness sessions report the same states themselves. Codex has no
hooks, but its dialogs are distinctive enough to read off the screen, so a Codex session
waiting on an approval, a question or the "Trust this folder?" prompt turns red too, and
clears a few seconds after you answer. For the other CLIs there are no hooks, so you get the
coarser output-based signal.
## Window title and OS notifications
@@ -109,7 +112,9 @@ Behaviour worth knowing:
## The Approvals Inbox
**Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal
front door reports its prompts to Codeman.**
front door reports its prompts to Codeman, and Codex sessions, whose dialogs Codeman reads
off the screen.** DeepSeek and Codex cards are alerts: they say "Answer in the terminal" and
carry no answer buttons, because Codeman cannot verify a keystroke into those dialogs.
One queue of every prompt currently waiting on a human, across all your sessions, answerable
in place. When you have eight workers running, this is the difference between checking eight
@@ -214,7 +219,8 @@ from the lock screen.
- **iOS needs the home screen install.** A Safari tab will never receive push.
- **The bell is invisible at zero.** That is deliberate, not a broken setting.
- **Approvals need real signals.** They are built on hook events, which Claude emits and
DeepSeek Harness reports itself; the other CLIs do neither.
DeepSeek Harness reports itself, and on Codex's dialogs as its screen shows them; the
other CLIs give neither.
- **A stale menu answer is refused, not sent.** If you answer a card for a dialog that has
since gone away, Codeman declines rather than typing a digit into the composer.
@@ -635,7 +635,7 @@ Three bounded long-polls. Shared semantics:
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer them in the terminal, never through the inbox) |
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
@@ -391,10 +391,10 @@ the worker remembering to print a token.
## Flow 5: watch for a worker stuck on a prompt
Claude workers can block on a permission dialog. `blocked` is a wait signal
(claude-mode only, and it needs Codeman's hooks in the worker's directory: see Flow 7
step 4), so watch for it and surface the question to the user instead of guessing an
answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
Claude workers can block on a permission dialog. `blocked` is a wait signal (a claude
worker needs Codeman's hooks in its directory for it: see Flow 7 step 4; a codex worker
gets it from its screen, see below), so watch for it and surface the question to the user
instead of guessing an answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
one (the same setting that decides whether the readiness marker in Flow 1 ever
appears):
@@ -409,10 +409,13 @@ if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
fi
```
Where the worker has no hooks, `blocked` never fires and a stuck worker looks exactly
like a slow one: your marker wait burns its whole bound. The fallback is the same
terminal tail, taken when a bound runs out, and the same rule about not answering it
yourself.
Codex workers are the one exception without hooks: Codeman reads their approval,
question and trust dialogs off the screen, so the same watch works with
`until=blocked,exit` (codex has no `stop`, a `400`), and the dialog is answered in the
terminal, never through the Approvals Inbox. Anywhere else without hooks, `blocked` never
fires and a stuck worker looks exactly like a slow one: your marker wait burns its whole
bound. The fallback is the same terminal tail, taken when a bound runs out, and the same
rule about not answering it yourself.
## Flow 6: claude fan-out over messaging
@@ -377,7 +377,9 @@ only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **an
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no**
`idle` transition at all, verified live), so synchronize those with markers.
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
+1 -1
View File
@@ -635,7 +635,7 @@ Three bounded long-polls. Shared semantics:
| `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
| `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer them in the terminal, never through the inbox) |
| `exit` | PTY exited or session deleted | every mode |
⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real
+11 -8
View File
@@ -391,10 +391,10 @@ the worker remembering to print a token.
## Flow 5: watch for a worker stuck on a prompt
Claude workers can block on a permission dialog. `blocked` is a wait signal
(claude-mode only, and it needs Codeman's hooks in the worker's directory: see Flow 7
step 4), so watch for it and surface the question to the user instead of guessing an
answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
Claude workers can block on a permission dialog. `blocked` is a wait signal (a claude
worker needs Codeman's hooks in its directory for it: see Flow 7 step 4; a codex worker
gets it from its screen, see below), so watch for it and surface the question to the user
instead of guessing an answer. Expect it routinely on a server whose `claudeMode` is not the default bypass
one (the same setting that decides whether the readiness marker in Flow 1 ever
appears):
@@ -409,10 +409,13 @@ if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
fi
```
Where the worker has no hooks, `blocked` never fires and a stuck worker looks exactly
like a slow one: your marker wait burns its whole bound. The fallback is the same
terminal tail, taken when a bound runs out, and the same rule about not answering it
yourself.
Codex workers are the one exception without hooks: Codeman reads their approval,
question and trust dialogs off the screen, so the same watch works with
`until=blocked,exit` (codex has no `stop`, a `400`), and the dialog is answered in the
terminal, never through the Approvals Inbox. Anywhere else without hooks, `blocked` never
fires and a stuck worker looks exactly like a slow one: your marker wait burns its whole
bound. The fallback is the same terminal tail, taken when a bound runs out, and the same
rule about not answering it yourself.
## Flow 6: claude fan-out over messaging
+3 -1
View File
@@ -377,7 +377,9 @@ only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **an
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no**
`idle` transition at all, verified live), so synchronize those with markers.
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
+1 -1
View File
@@ -914,7 +914,7 @@ export function registerAgentCommands(program: Command): Command {
)
.option(
'-u, --until <signals>',
'Comma list: stop,idle,exit,working,blocked (stop/blocked need hook signals for the session; where there are none the server answers 400, passed through)'
'Comma list: stop,idle,exit,working,blocked (stop needs hook signals for the session, blocked hooks or a dialog Codeman reads off the screen, as for codex; where there are none the server answers 400, passed through)'
)
.option(
'-m, --match <marker>',
+20
View File
@@ -368,6 +368,22 @@ const capabilitiesSchema = z
'awaitingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Same guard again: run over the foot of a pane capture every time a session
// settles and on a timer while a dialog is up, and ~/.codeman/clis.json can set it.
blockedLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'blockedLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
)
.optional(),
// Wider than `watchingLines` because a dialog's options wrap at narrow widths (codex
// needs 20 at 36 columns). That is safe only for a pattern that ends on the dialog's
// own footer: the rows above it, which the agent may write, can then supply a
// marker but never the footer. Still bounded, since it is also how much text the
// pattern runs over.
blockedLines: z.number().int().min(1).max(24).optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
@@ -378,6 +394,10 @@ const capabilitiesSchema = z
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.refine(
(v) => v.blockedLines === undefined || v.blockedLine !== undefined,
'blockedLines has nothing to bound without a blockedLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
+37 -4
View File
@@ -677,10 +677,11 @@ const CODEX: CliEntry = {
// which the agent writes (and while a prompt is typed, the last two). Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
// What contains it is that codex declares `hooks: 'none'`: no IDLE prompt from a codex
// session ever reaches `notePrompt()` (its screen-read dialogs below open PERMISSION
// items, which a watching label never acknowledges), so there is no idle item to
// pre-acknowledge and a forged label costs a wrong badge and nothing else. A CLI that
// gains idle signals must not keep a pattern this soft.
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
// A sub-agent started without waiting outlives the turn just as a terminal does —
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
@@ -689,11 +690,43 @@ const CODEX: CliEntry = {
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
// match until codex pins one.
// Codex is the one CLI Codeman launches with its approvals on by default, and it has no
// hook to say it stopped for one, so its dialogs are read off the screen. Measured on a
// live codex-cli 0.162.0 pane on 2026-10-14 (`-a on-request -s read-only`, widths 200,
// 80, 60, 44 and 36): every dialog it blocks on REPLACES the composer and its footer
// with a numbered option list, the selected one marked `›`, over a key-hint row:
// command approval (a network `› 1. Yes, proceed (y)` ...
// escalation too), edit approval
// ` Press enter to confirm or esc to cancel`
// question (request_user_input) ` › 1. Blue Write the color blue.` ...
// ` tab to add notes | enter to submit answer | esc to interrupt`
// (its notes field open) ` tab or esc to clear notes | enter to submit answer`
// workspace trust, at startup `› 1. Trust and continue`
// ` enter continue · esc quit`
// The pattern needs both: a `› N.` option row, then the key-hint row, which must END
// the window. Below about 60 columns that hint wraps onto rows of its own
// (` Press enter to confirm or esc to` / ` cancel`, or `tab to add notes` /
// `enter to submit answer` / `esc to interrupt` one per row), so the hint may run on
// over rows that start with two spaces and a lowercase word or arrow, and must end on
// one of its own closing words. The transcript can quote every one of those strings
// (measured: an answer that printed them verbatim) and still not match, because while
// no dialog is up codex keeps its composer row (`› ...`, column 0, which ends the
// run) and its footer below anything the agent prints. The longest option list
// measured, a question at 36 columns, put the `›` row 16 non-blank rows up; 20 leaves
// room for longer option text, and a dialog whose marker scrolls out of the window is
// simply not seen (the behaviour before this field). Not matched on purpose:
// `Implement this plan?` after a plan turn, whose `enter select · esc back` footer is
// the same as `/model`, `/permissions` and `/review`, menus the user opened at the
// keyboard; the plan turn has ended there, and the session reads idle as before.
// ⚠️ The question dialog's hint contains `esc to interrupt`, the working line, so
// such a pane read as WORKING before this field: a blocked screen now wins over it.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 4,
blockedLine: String.raw`(?:^|\n) {0,4}› \d\. [\s\S]*\n {2}(?:Press enter to confirm|tab (?:to add|or esc to clear) notes|enter continue)(?:[^\n]|\n {2}(?=[a-z←])){0,120}(?:cancel|interrupt|quit|answer|all)$`,
blockedLines: 20,
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
+26
View File
@@ -406,6 +406,32 @@ export interface CliCapabilities {
* tested. See `isAwaitingWorkers()` in `session-activity.ts`.
*/
awaitingLine?: string;
/**
* Source of a regex matching the dialog this CLI draws when it stops and waits for the
* user to answer something (an approval, a question, a trust prompt): Codeman reads
* such a pane as BLOCKED, with the same red "needs you" alert, push, webhook and
* `blocked` wait signal a Claude `permission_prompt` hook raises. It is the source of
* that state for a CLI with no hook of its own (`hooks: 'none'`); a CLI whose hooks
* report it never consults the screen for it.
*
* Tested against the last `blockedLines` non-blank rows of a pane capture, joined
* with `\n`, so the pattern can require several markers at once and anchor on the
* end of the window (no `m` flag: `$` is the last row). It must anchor on chrome
* only that CLI draws, at the FOOT of the screen, where a dialog replaces the
* composer: the text above is the agent's own, and it can print anything. A forged
* match may cost a false alert and nothing else, because Codeman never answers such a
* dialog. See `isBlockedScreen()` in `session-activity.ts`.
*
* A CLI that omits it is never blocked from the screen, which is what every CLI was
* before the field existed.
*/
blockedLine?: string;
/**
* How many non-blank rows at the foot of the screen `blockedLine` sees. A dialog's
* options wrap at narrow widths, which is what pushes its markers up the screen, so
* this is sized for the narrowest pane the CLI was measured at. Default 1.
*/
blockedLines?: number;
};
/**
* How many columns this CLI indents its transcript body by, so a copy taken from its
+2 -1
View File
@@ -270,7 +270,8 @@ export function buildToolDefinitions(modes: readonly string[]): ToolDefinition[]
'prompt, idle, working, exit) or prints a literal marker (match). Returns timed_out when timeout_ms passes: ' +
'call again to keep waiting. Your own prompt is echoed into the output, so a marker written whole in the ' +
'prompt matches at once: ask for it in two halves ("print DONE followed by _7f3a") and match the joined ' +
'form (DONE_7f3a), one word without spaces. stop and blocked exist only for claude and deepseek sessions.',
'form (DONE_7f3a), one word without spaces. stop exists only for claude and deepseek sessions; blocked ' +
'also for codex, whose dialogs Codeman reads off its screen.',
inputSchema: {
type: 'object',
properties: {
+71
View File
@@ -159,6 +159,77 @@ 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;
/**
* 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
+159 -1
View File
@@ -89,7 +89,12 @@ import {
isPaneQuiet,
watchingLabel,
isAwaitingWorkers,
isBlockedScreen,
WATCHING_TAIL_LINES,
BLOCKED_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_QUIET_RECHECK_MS,
BLOCKED_CLEAR_READS,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
@@ -596,6 +601,23 @@ export class Session extends EventEmitter {
private _watchingWindow = WATCHING_TAIL_LINES;
/** Lazily compiled `capabilities.workDetect.awaitingLine`. See _awaitingLinePattern(). */
private _awaitingLineRe: RegExp | null | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.blockedLine`. See _blockedLinePattern(). */
private _blockedLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
private _blockedWindow = BLOCKED_TAIL_LINES;
/**
* The pane is showing a dialog the CLI blocks on, read off the screen with
* `capabilities.workDetect.blockedLine` (codex: an approval, a question, the trust
* prompt). Live state, not persisted: a restarted server re-reads it from the attach
* repaint the same way the watching label comes back. See `_noteBlockedReading()`.
*/
private _screenBlocked = false;
/** Consecutive readable captures without the dialog (cleared at BLOCKED_CLEAR_READS). */
private _blockedGoneReads = 0;
/** When the last reading that SAW the dialog was taken. See `_armBlockedRecheck()`. */
private _blockedSeenAt = 0;
/** Re-reads the screen while a dialog is up, so the alert clears with it. */
private _blockedRecheckTimer: NodeJS.Timeout | null = null;
/**
* The newest model the running CLI reported for itself (its statusline, or its own
* footer read off the probe's capture), or null when none has. Feeds `displayModel`
@@ -1429,6 +1451,16 @@ export class Session extends EventEmitter {
return this._watching;
}
/**
* True while the pane shows a dialog its CLI is blocked on, as read off the screen
* (`capabilities.workDetect.blockedLine`). Always false for a CLI that declares none,
* Claude included: its dialogs arrive as hook events instead. The `blocked` wait reads
* it so a caller that asks after the dialog appeared is answered at once.
*/
get screenBlocked(): boolean {
return this._screenBlocked;
}
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Null until the first
@@ -3081,6 +3113,10 @@ export class Session extends EventEmitter {
this._expensiveProcessTimer = null;
}
this._pendingCleanData = '';
// Nothing reads this pane any more, so a dialog on it can no longer be seen to
// close: drop the state (and its timer) while the listeners are still attached.
// A re-attach re-reads the screen and raises it again if the dialog is still up.
this._clearScreenBlocked();
// If using mux, mark the session as detached but don't kill it
if (this._muxSession && this._mux) {
this._mux.setAttached(this.id, false);
@@ -3265,10 +3301,19 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
// A dialog the CLI is blocked on is not work, whatever else is on screen: codex's
// question dialog carries `esc to interrupt` in its own key hints, which is codex's
// working line, so without this a session waiting on the user's answer read as busy.
// Decided from THIS capture, never from `_screenBlocked`, which lags a vanished dialog
// by design (see `_noteBlockedReading`) and would otherwise call a resumed turn idle.
const blockedOnScreen = text === null ? null : this._paneShowsBlocked(text);
this._noteBlockedReading(blockedOnScreen, text);
// A turn that ended by handing off to workers the CLI waits for is work too: the
// composer is up and the pane is quiet, but the next turn starts without the user.
this._lastPaneProbeWorking =
text === null ? null : this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text);
text === null
? null
: !blockedOnScreen && (this._workingLinePattern().test(text) || this._paneAwaitsWorkers(text));
this._readWatching(text);
this._readScreenModel(text);
return this._lastPaneProbeWorking;
@@ -3462,6 +3507,111 @@ export class Session extends EventEmitter {
return isAwaitingWorkers(paneText, this._awaitingLineRe, glyph);
}
/**
* The regex matching the dialog this CLI blocks on, or null for a CLI whose registry
* entry declares none (every CLI but codex today, Claude included: its dialogs arrive
* as hook events). Compiled once per session through `compileVersionRegex()`, the same
* guard the schema applies, so a pattern it refuses yields null rather than a throw.
*/
private _blockedLinePattern(): RegExp | null {
if (this._blockedLineRe === undefined) {
const detect = getCli(this.mode)?.capabilities.workDetect;
this._blockedLineRe = detect?.blockedLine ? compileVersionRegex(detect.blockedLine) : null;
this._blockedWindow = detect?.blockedLines ?? BLOCKED_TAIL_LINES;
}
return this._blockedLineRe;
}
/** Whether this capture shows a dialog the CLI is blocked on. See `isBlockedScreen()`. */
private _paneShowsBlocked(paneText: string): boolean {
const pattern = this._blockedLinePattern();
return pattern ? isBlockedScreen(paneText, pattern, this._blockedWindow) : false;
}
/**
* Fold one screen reading into the blocked state, announcing each transition with
* `blockedChanged` (the web server turns it into the alert and the `blocked` signal).
*
* The two directions are deliberately unequal:
* - RAISE on one reading. A match needs the dialog's own footer at the foot of the
* screen, which is only there when the dialog is fully drawn.
* - CLEAR on BLOCKED_CLEAR_READS readings in a row. A single capture can land inside a
* full-frame repaint, and clearing on it would re-raise a moment later and send the
* alert, push and webhook twice for one dialog.
* A capture that could not be read (`null`) is no evidence either way and changes
* nothing, so a tmux hiccup can neither raise nor drop an alert. That is the opposite
* of `_readWatching`, and for the same reason: each degrades toward the alert.
*
* While blocked, `_blockedRecheckTimer` keeps reading, since answering resumes the turn
* and a busy pane never reaches the settle path that raised it.
*
* @param frame the capture the reading came from; rides the raise as the alert's context
*/
private _noteBlockedReading(onScreen: boolean | null, frame: string | null): void {
if (onScreen === null || !this._blockedLinePattern()) return;
if (onScreen) {
this._blockedGoneReads = 0;
// Stamped after the capture returned: output processed later is newer than it.
this._blockedSeenAt = Date.now();
if (!this._screenBlocked) {
this._screenBlocked = true;
this.emit('blockedChanged', true, frame);
}
this._armBlockedRecheck();
return;
}
if (!this._screenBlocked) return;
if (++this._blockedGoneReads < BLOCKED_CLEAR_READS) {
this._armBlockedRecheck();
return;
}
this._clearScreenBlocked();
}
/** (Re)arm the while-blocked screen read. One-shot and self-rearming, like the trust scan. */
private _armBlockedRecheck(): void {
if (this._blockedRecheckTimer) clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = setTimeout(() => {
this._blockedRecheckTimer = null;
// No pane left to read (detached): nothing can change until a re-attach, whose
// `_resetBuffers()` clears the state and whose repaint reads the screen afresh.
if (this._isStopped || !this._screenBlocked || !this._mux?.capturePaneText || !this._muxSession) return;
// A dialog leaves the screen only through a repaint, and tmux sends every repaint
// to this client as PTY output (`_markActivity`). So while the last reading saw
// the dialog and nothing has been drawn since, it is still up: skip the capture
// (synchronous, see BLOCKED_QUIET_RECHECK_MS) and look again next interval. Never
// once a reading has missed it: the confirming read must happen on a quiet pane
// too, or the clear would wait for output that may never come.
if (
this._blockedGoneReads === 0 &&
this._lastActivityAt < this._blockedSeenAt &&
Date.now() - this._blockedSeenAt < BLOCKED_QUIET_RECHECK_MS
) {
this._armBlockedRecheck();
return;
}
const text = this._mux.capturePaneText(this._muxSession.muxName) ?? null;
// Unreadable this time: keep the state, keep looking (_noteBlockedReading ignores a null).
if (text === null) {
this._armBlockedRecheck();
return;
}
this._noteBlockedReading(this._paneShowsBlocked(text), text);
}, BLOCKED_RECHECK_MS);
}
/** Drop the blocked state (and its timer), announcing it when it was set. */
private _clearScreenBlocked(): void {
this._blockedGoneReads = 0;
if (this._blockedRecheckTimer) {
clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = null;
}
if (!this._screenBlocked) return;
this._screenBlocked = false;
this.emit('blockedChanged', false, null);
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
@@ -4004,6 +4154,9 @@ export class Session extends EventEmitter {
// into a program that never enabled tracking.
this._cliMouseModes.clear();
this._syncCliMouseTracking();
// Nor does it start inside a dialog. Announced, so a pending alert for the old
// pane's dialog is resolved rather than left behind it.
this._clearScreenBlocked();
this._markActivity(true);
}
@@ -4016,6 +4169,11 @@ export class Session extends EventEmitter {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// Clear the while-blocked screen read (the state itself is left to its owner)
if (this._blockedRecheckTimer) {
clearTimeout(this._blockedRecheckTimer);
this._blockedRecheckTimer = null;
}
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
+13 -2
View File
@@ -15,7 +15,9 @@
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
* - Items are in-memory only. A server restart drops them; the next prompt
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
* re-fires the hook. Answerable items are Claude's alone; the DeepSeek status
* bridge and the screen-read dialogs of a hook-less CLI (codex, see
* `web/screen-blocked.ts`) open alert-only items (`answerInTerminal`).
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
@@ -24,7 +26,8 @@
*
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
* web/session-listener-wiring (working/exit resolution), web/screen-blocked (screen-read
* dialogs), web/server (emit callbacks + stop)
*
* @module web/approval-inbox
*/
@@ -122,6 +125,13 @@ interface NotePromptArgs {
answerInTerminal?: boolean;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
/**
* A frame the caller already read, kept as the card's context when there is no
* `capture`. Never parsed for options and never re-read: an item raised from the
* screen (`web/screen-blocked.ts`) belongs to the session that keeps reading it, and
* the option parser and staleness sweep here are shaped for Claude's dialogs.
*/
context?: string;
}
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -279,6 +289,7 @@ export class ApprovalInbox {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
}
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
+3
View File
@@ -1287,6 +1287,9 @@
(_m, n) => `窗口太小,容纳不下 ${n} 个窗格:只显示聚焦的窗格`,
],
[/^The agent exited \((-?\d+)\)$/, (_m, code) => `智能体已退出(${code})`],
// A dialog read off a hook-less CLI's screen (src/web/screen-blocked.ts), as the
// notification drawer shows it. The CLI's label passes through.
[/^(.+) is waiting for your answer in the terminal$/, (_m, cli) => `${cli} 正在终端中等待你的回答`],
[/^The agent exited \(signal (\d+)\)$/, (_m, signal) => `智能体已退出(信号 ${signal})`],
// A session header's harness logo (tile grid, split pane): "<harness> · <model>",
// and where the model came from when the CLI did not report it. The harness
+5 -1
View File
@@ -35,7 +35,11 @@ Object.assign(CodemanApp.prototype, {
this.setPendingHook(data.sessionId, 'permission_prompt');
}
const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue');
// A dialog read off a hook-less CLI's screen (codex; `source: 'screen'`, see
// src/web/screen-blocked.ts) carries a server-built message naming that CLI, so the
// notification does not claim it is Claude asking.
const screenMessage = data.source === 'screen' && data.message ? data.message : '';
this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || screenMessage || 'Claude needs tool approval to continue');
},
_onHookElicitationDialog(data) {
+13 -9
View File
@@ -24,6 +24,7 @@ import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { getCli } from '../../config/cli-registry/registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,20 +100,23 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Alert-only items (supervised-hook CLIs such as DeepSeek Harness): the
// dialog belongs to a third-party TUI whose keystroke contract Codeman has
// not measured, so '1'/Esc would be a blind keystroke into a foreign
// composer. The item still raises the red alert and clears on the harness's
// own working/stop reports; answering happens in the terminal.
// Alert-only items: the dialog belongs to a TUI whose keystroke contract Codeman
// has not measured, so '1'/Esc would be a blind keystroke into a foreign composer.
// Two sources today: a supervised-hook CLI (DeepSeek Harness), whose item clears on
// the harness's own working/stop reports, and a dialog read off the screen of a
// hook-less CLI (codex, web/screen-blocked.ts), which clears when it leaves the
// screen and was read from text the agent writes, so it must never steer a key.
// Checked BEFORE the hooks gate below: a codex session has no hooks, and "this mode
// cannot have pending approvals" would be false about the item the caller holds.
if (item.answerInTerminal) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
`${getCli(session.mode)?.label ?? session.mode} approvals must be answered in the terminal: the dialog belongs to a TUI whose keystrokes Codeman cannot verify.`
);
}
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+25 -2
View File
@@ -678,6 +678,27 @@ function currentSignalFor(session: { pid: number | null; status: SessionStatus }
return signalForStatus(session.status);
}
/**
* `currentSignalFor`, answering `blocked` for a session whose pane is showing a dialog
* its CLI is blocked on (`Session.screenBlocked`, read off the screen for a CLI with no
* hook: codex) when the caller asked for it. A hook-raised `blocked` is an edge with no
* history, but this one is a level the server is still looking at, so a caller that
* asks after the dialog appeared is answered at once instead of waiting out its timeout
* for an edge that already passed. Only when `blocked` was asked for: such a pane is also
* `idle`, and a caller that asked for `idle` alone must keep getting `idle`. Used by
* `GET .../wait` and by a DUPLICATE send-and-wait, every place a wait answers from the
* session's current state.
*/
function currentSignalForWait(
session: { pid: number | null; status: SessionStatus; screenBlocked?: boolean },
dead: boolean,
until: readonly WaitSignal[]
): WaitSignal | null {
const signal = currentSignalFor(session, dead);
if (signal !== 'exit' && session.screenBlocked && until.includes('blocked')) return 'blocked';
return signal;
}
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
//
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
@@ -1998,7 +2019,9 @@ export function registerSessionRoutes(
// A DUPLICATE has no new turn coming, so it answers from the current state
// instead of blocking for a transition that already happened.
requireTransition: !duplicate,
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
// The `...ForWait` variant, as on GET .../wait: a screen-read dialog the first
// delivery raised is a level, and a retry asking for `blocked` must find it.
currentSignal: duplicate ? currentSignalForWait(session, workerDead, until) : undefined,
});
} catch (err) {
if (err instanceof WaitCapacityError) {
@@ -2182,7 +2205,7 @@ export function registerSessionRoutes(
abortSignal: abort.signal,
requireTransition: query.fresh === '1' || query.fresh === 'true',
// Read BEFORE awaiting: this is the state the caller is asking about.
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
currentSignal: currentSignalForWait(session, workerIsDead(ctx.mux, session), until),
});
return {
+135
View File
@@ -0,0 +1,135 @@
/**
* @fileoverview Screen-detected blocked dialogs: what the server does with them.
*
* A CLI that has no hook to say "I stopped to ask you something" (codex: `hooks: 'none'`,
* and the one CLI Codeman launches with its approvals on by default) declares
* `capabilities.workDetect.blockedLine`, and `Session` reads its dialogs off the pane and
* emits `blockedChanged` (see `_noteBlockedReading()` in session.ts). This module turns
* that edge into exactly what a Claude `permission_prompt` hook produces in
* `hook-event-routes.ts`, so every surface that already knows that event (the red tab
* alert and desktop notification, web push, webhooks, the phone overview's NEEDS YOU, the
* Approvals Inbox, `codeman tui`, the run summary and the `blocked` wait signal) needs no
* second code path:
*
* - `raiseScreenBlocked()`: the `blocked` wait signal, an Approvals Inbox item, the
* `hook:permission_prompt` broadcast and the push/webhook.
* - `clearScreenBlocked()`: resolve that item, which broadcasts `approval:resolved`, and
* every client clears the alert from it (`_onApprovalResolved`, approvals-ui.js).
*
* Rules it keeps:
* - ⚠️ ALERT ONLY. The item is `answerInTerminal` (the #610 shape the DeepSeek Harness
* items use): the answer route refuses it, push carries no Approve/Deny, and the inbox
* card and the overview strip say "answer in the terminal". The dialog is read
* off a screen the agent writes most of, so a forged match must cost a false alert and
* never a keystroke. Codeman does not answer these dialogs.
* - The item gets no `capture` callback, only the frame it was raised from as context:
* the inbox's option parser and staleness sweep are shaped for Claude's dialogs, and the
* SESSION owns this item's lifetime (it re-reads the screen while the dialog is up).
* - Hooks win. A session whose CLI reports blocked itself (`hooksAvailableForMode()`)
* gets nothing here, so a CLI that declared both could never alert twice for one dialog.
* - Only the item this module opened is resolved on clear, never a hook-raised one.
*
* @dependencies approval-inbox, session-wait-registry, sse-events, cli-registry
* @consumedby web/session-listener-wiring (blockedChanged)
* @module web/screen-blocked
*/
import { getCli } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
import { approvalInbox } from './approval-inbox.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from './session-wait-registry.js';
import { SseEvent } from './sse-events.js';
/** Marks a screen-read event, so a consumer can tell it from a hook-raised one. */
export const SCREEN_BLOCKED_SOURCE = 'screen';
/** The facts about a session this module reads (structural, so tests need no `Session`). */
export interface ScreenBlockedSession {
id: string;
name: string;
mode: SessionMode;
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}
/** What the server lends it: the same channels the hook route uses. */
export interface ScreenBlockedDeps {
broadcast(event: string, data: unknown): void;
broadcastSessionStateDebounced(sessionId: string): void;
sendPushNotifications(event: string, data: Record<string, unknown>): void;
getRunSummaryTracker?(
sessionId: string
): { recordHookEvent(event: string, data?: Record<string, unknown>): void } | undefined;
}
/**
* The line every surface shows for such a dialog. Built from the CLI's own label (data,
* never an id branch), and from nothing on the screen: the dialog's wording is the
* agent's to choose (a question's text, a command preview), so none of it reaches a push
* or a webhook. The approval card shows the frame itself, escaped.
*/
export function screenBlockedMessage(mode: SessionMode): string {
return `${getCli(mode)?.label ?? mode} is waiting for your answer in the terminal`;
}
/**
* The pane now shows a dialog its CLI is blocked on: raise everything a Claude
* `permission_prompt` hook would.
*
* @param frame the capture the dialog was read from, kept as the card's context
* @returns the id of the Approvals Inbox item opened, or null when nothing was raised
*/
export function raiseScreenBlocked(
session: ScreenBlockedSession,
deps: ScreenBlockedDeps,
frame?: string | null
): string | null {
if (hooksAvailableForMode(session.mode, sessionHookOptions(session))) return null;
const message = screenBlockedMessage(session.mode);
// Before anything that can throw, like the hook route: a caller parked on `blocked`
// is the reason this exists for agents.
sessionWaits.notifySignal(session.id, 'blocked');
const item = approvalInbox.notePrompt({
sessionId: session.id,
sessionName: session.name,
kind: 'permission',
message,
answerInTerminal: true,
context: frame ?? undefined,
});
const data = { source: SCREEN_BLOCKED_SOURCE, message };
deps.broadcast(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
timestamp: Date.now(),
...data,
approvalId: item.id,
});
// Same ride-along as the hook route: the home screens rank the blocked group on the
// session's stamps, so the row moves without waiting for some unrelated event.
deps.broadcastSessionStateDebounced(session.id);
// No approvalId: push would offer Approve/Deny for a dialog nobody here may answer.
deps.sendPushNotifications(SseEvent.HookPermissionPrompt, {
sessionId: session.id,
sessionName: session.name,
...data,
});
deps.getRunSummaryTracker?.(session.id)?.recordHookEvent('permission_prompt', data);
return item.id;
}
/**
* The dialog left the screen (answered, cancelled, or the pane went away): resolve the
* item `raiseScreenBlocked()` opened, which clears the alert on every client. An item
* that is no longer that one (dismissed, expired, or superseded by a hook) is left alone.
*
* @param itemId what `raiseScreenBlocked()` returned
*/
export function clearScreenBlocked(sessionId: string, itemId: string | null): void {
if (!itemId) return;
if (approvalInbox.getForSession(sessionId)?.id !== itemId) return;
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
+9
View File
@@ -112,6 +112,7 @@ import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -2835,6 +2836,14 @@ export class WebServer extends EventEmitter {
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
} else if (event === SseEvent.HookPermissionPrompt && data.source === SCREEN_BLOCKED_SOURCE && data.message) {
// A dialog read off a hook-less CLI's screen (web/screen-blocked.ts), whose message
// is built from the CLI's label, never from the screen. Keyed on the source so a
// Claude hook's own `message` keeps the body it always had; capped like the error
// text, since a hook poster holding the secret can claim the source too (as it can
// already claim any `tool_name` above).
body += body ? ' ' : '';
body += String(data.message).slice(0, 200);
}
return body;
}
+21
View File
@@ -29,6 +29,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
import { raiseScreenBlocked, clearScreenBlocked } from './screen-blocked.js';
import { composeAutoSessionName, deriveAutoSessionName } from '../session-auto-name.js';
import { MAX_SESSION_NAME_LENGTH } from '../config/terminal-limits.js';
@@ -44,6 +45,7 @@ export interface SessionListenerRefs {
working: () => void;
idle: () => void;
watchingChanged: () => void;
blockedChanged: (blocked: boolean, frame: string | null) => void;
displayModelChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
@@ -98,6 +100,8 @@ interface SessionListenerDeps {
* Call `attachSessionListeners()` after to wire them to the session.
*/
export function createSessionListeners(session: Session, deps: SessionListenerDeps): SessionListenerRefs {
/** The Approvals Inbox item the session's last screen-read dialog opened (see blockedChanged). */
let screenBlockedItemId: string | null = null;
return {
// ─── Terminal Output ─────────────────────────────────────
@@ -276,6 +280,21 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* The pane started or stopped showing a dialog its CLI is blocked on, read off the
* screen for a CLI with no hook to report it (`capabilities.workDetect.blockedLine`:
* codex). Raised as a Claude `permission_prompt` hook would be, alert-only, and
* cleared by resolving the item it opened; web/screen-blocked.ts has the rules.
*/
blockedChanged: (blocked: boolean, frame: string | null) => {
if (blocked) {
screenBlockedItemId = raiseScreenBlocked(session, deps, frame);
} else {
clearScreenBlocked(session.id, screenBlockedItemId);
screenBlockedItemId = null;
}
},
/**
* Pushes the session state when the model the CLI reports changes (its statusline,
* or its own footer), which the status does not reflect. Persisted too: a restart
@@ -519,6 +538,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('blockedChanged', refs.blockedChanged);
session.on('displayModelChanged', refs.displayModelChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
@@ -557,6 +577,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('blockedChanged', refs.blockedChanged);
session.off('displayModelChanged', refs.displayModelChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
+35 -14
View File
@@ -28,11 +28,17 @@
* `idle` / `working` / `exit` come from `Session`'s own events (heuristic: output
* stabilization plus prompt detection), so `idle` can flap mid-turn when a spinner
* pauses. `stop` and `blocked` come from Claude Code hooks (`POST /api/hook-event`)
* and are definitive. Only `claude` mode installs those hooks, so `stop` and
* `blocked` never fire for anything else: callers must be rejected up front rather
* than left to hit the timeout. `hooksAvailableForMode()` keeps that rule in one
* place, and it is keyed on the MODE rather than on `isExternalCliMode()` because
* `shell` is not an external CLI yet installs no hooks either.
* and are definitive. Only `claude` mode installs those hooks (and `deepseek`'s status
* bridge reports the same two), so `stop` never fires for anything else: callers must
* be rejected up front rather than left to hit the timeout. `hooksAvailableForMode()`
* keeps that rule in one place, and it is keyed on the MODE rather than on
* `isExternalCliMode()` because `shell` is not an external CLI yet installs no hooks
* either.
*
* `blocked` has a second source: a CLI with no hook for it can declare the dialogs it
* blocks on (`capabilities.workDetect.blockedLine`, codex), and `Session` reads them off
* the screen (`web/screen-blocked.ts`). `blockedSignalAvailable()` is the rule for that
* signal. `stop` has no such source, because nothing on a screen says a turn is over.
*
* ## Ordering contract for the wiring
*
@@ -152,7 +158,9 @@ export function parseWaitSignals(raw: unknown): ParsedWaitSignals {
* `stopped` and `error` both map to `exit` because both mean "not running", so a
* caller that does catch one of them does not block. `blocked` is deliberately
* underivable here, it exists only as a hook event until it becomes a real
* `SessionStatus` (see the deferred Part 3 in the plan).
* `SessionStatus` (see the deferred Part 3 in the plan). A screen-read dialog IS a
* level a later caller can find (`Session.screenBlocked`), and the wait route answers
* that one itself.
*/
export function signalForStatus(status: SessionStatus): WaitSignal | null {
switch (status) {
@@ -168,9 +176,6 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
}
}
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
export interface HookCapabilityOptions {
/**
@@ -266,6 +271,19 @@ export function sessionHookOptions(session: {
};
}
/**
* Whether `blocked` can ever fire for this session: its CLI reports it through hooks
* (`hooksAvailableForMode()`), or Codeman reads the dialogs it blocks on off its screen
* (`capabilities.workDetect.blockedLine`, codex). The screen half needs no per-session
* facts: a remote or Docker pane is still a local tmux pane showing the CLI's screen
* (their nested tmux runs with `status off`, so nothing is drawn below the CLI).
*
* ⚠️ Says nothing about `stop`. A screen shows a dialog; it never says a turn ended.
*/
export function blockedSignalAvailable(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
return hooksAvailableForMode(mode, options) || !!getCli(mode)?.capabilities.workDetect?.blockedLine;
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
export interface ResolvedWaitSignals {
/** Signals to actually wait on. Empty when `error` is set. */
@@ -279,9 +297,9 @@ export interface ResolvedWaitSignals {
* every wait endpoint shares:
*
* 1. An unknown token is an ERROR, never a silent fallback to the default.
* 2. Hook-only signals are rejected when asked for EXPLICITLY on a mode that emits
* no hooks, but merely dropped from the DEFAULT set: omitting the parameter must
* never 400.
* 2. A signal the session can never emit (`stop` without hooks, `blocked` without
* hooks or a screen-read `blockedLine`) is rejected when asked for EXPLICITLY, but
* merely dropped from the DEFAULT set: omitting the parameter must never 400.
*
* Shared by `GET .../wait` and the `wait` field on `POST .../input` so the two can
* not drift; the second-guessing that produces is worse than the duplication.
@@ -304,7 +322,9 @@ export function resolveWaitSignals(
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
const unsupported = new Set<WaitSignal>();
if (!hooksAvailableForMode(options.mode, options)) unsupported.add('stop');
if (!blockedSignalAvailable(options.mode, options)) unsupported.add('blocked');
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
@@ -324,7 +344,8 @@ export function resolveWaitSignals(
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). ` +
`Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`,
};
}
return { until: parsed.signals, error: null };
+7 -2
View File
@@ -26,7 +26,8 @@
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (10): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed, prompt_submitted
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook;
* permission_prompt also arrives with `source: 'screen'` for a codex dialog read off its screen)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
@@ -365,7 +366,11 @@ export const AttachmentDetected = 'attachment:detected' as const;
/** Claude Code hook: session idle, waiting for input. */
export const HookIdlePrompt = 'hook:idle_prompt' as const;
/** Claude Code hook: tool requesting permission. */
/**
* Claude Code hook: tool requesting permission. Also raised by the server itself, with
* `source: 'screen'`, for a dialog read off a hook-less CLI's screen (codex; see
* `web/screen-blocked.ts`), which is cleared by `approval:resolved`.
*/
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
/** Claude Code hook: elicitation dialog (Claude asking a question). */
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
+57
View File
@@ -183,6 +183,63 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine,
};
},
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
);
}
});
it('bounds the blockedLine window and refuses one with no pattern', () => {
for (const blockedLines of [0, 25, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLines: 4,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts a blockedLine with its window, and every shipped one', () => {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines: 24,
};
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
let shipped = 0;
for (const stock of STOCK_CLIS) {
const src = stock.capabilities.workDetect?.blockedLine;
if (!src) continue;
shipped++;
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
}
expect(shipped).toBeGreaterThan(0);
});
});
describe('no shell text can reach the command line', () => {
+417
View File
@@ -0,0 +1,417 @@
/**
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
*
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
* to run the following command?" used to read as plainly idle, and one parked on a
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
* session reads it at every settle and on a timer while it is up, and the server raises
* exactly what a Claude `permission_prompt` hook raises, alert-only.
*
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import {
isBlockedScreen,
BLOCKED_TAIL_LINES,
BLOCKED_MAX_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_CLEAR_READS,
BLOCKED_QUIET_RECHECK_MS,
IDLE_SILENCE_MS,
PANE_PROBE_MIN_INTERVAL_MS,
} from '../src/session-activity.js';
import type { SessionMode } from '../src/types.js';
const DETECT = getCli('codex')!.capabilities.workDetect!;
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
const CODEX_WINDOW = DETECT.blockedLines!;
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
/** Every dialog codex was measured blocking on. */
const DIALOGS = [
'exec-approval.txt',
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
'exec-approval-w44.txt',
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
'edit-approval.txt',
'network-escalation-approval.txt',
'question.txt',
'question-option2-selected.txt',
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
'question-w60.txt',
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
'question-notes-typed.txt',
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
'trust-dialog-w40.txt',
];
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
const NOT_DIALOGS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
'review-popup.txt',
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
];
describe('codex blockedLine on live captures', () => {
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
expect(CODEX_BLOCKED).not.toBeNull();
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
});
it.each(DIALOGS)('reads %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(true);
});
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(false);
});
it('needs the whole window for the narrowest question, and no more than it declares', () => {
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
// that stops short of it reads nothing, which is the pre-field behaviour, never a
// false alert.
const narrow = fixture('question-w36.txt');
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
});
it('sees the question dialogs that the working line used to claim', () => {
// Why a blocked screen has to WIN over the working line in the probe: the question's
// own key hints end in `esc to interrupt`.
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
expect(blocked(fixture(name)), name).toBe(true);
}
});
});
describe('codex blockedLine against a screen the agent writes', () => {
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
// path vary), hint row.
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
`› ${typed}`,
'',
footer,
' ? for shortcuts',
];
const forgedDialog = [
'• Here is what the dialog looks like:',
'› 1. Yes, proceed (y)',
' 2. No, and tell Codex what to do differently (esc)',
'',
' Press enter to confirm or esc to cancel',
];
it('refuses a forged dialog in the transcript, whatever follows it', () => {
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
// While a prompt is typed the hint row goes away, so the forged hint sits only two
// rows above the end: the composer row (column 0, `›`) still breaks the run.
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
// Even with a lowercase model and a short path, the footer row would have to end in
// one of the hint's own closing words.
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
});
it('refuses the working view with the dialog quoted right above the status line', () => {
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
expect(blocked(screen)).toBe(false);
});
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
expect(
blocked(
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
)
).toBe(false);
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
});
it('stays linear on a hostile window', () => {
// Many option rows and long rows, ending in something that is almost the hint: the
// pattern must give up quickly, since it runs on a timer while a dialog is up.
const row = `› 1. ${'x'.repeat(300)}`;
const screen = [
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
` Press enter to confirm ${'or '.repeat(60)}`,
].join('\n');
const started = performance.now();
for (let i = 0; i < 50; i++) blocked(screen);
expect(performance.now() - started).toBeLessThan(250);
expect(blocked(screen)).toBe(false);
});
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
const dialog = fixture('exec-approval.txt');
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
});
it('answers false for an empty or unreadable capture', () => {
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
});
});
// ─── The session's blocked state ────────────────────────────────────────────
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* What the pane shows, and how often the session has read it: every read is a synchronous
* `tmux capture-pane` on the live server.
*/
type FakeScreen = { text: string | null; captures?: number };
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => {
screen.captures = (screen.captures ?? 0) + 1;
return screen.text;
},
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/**
* The pane changes the only way a real one can: tmux redraws it to the attached client,
* which reaches the session as output. A cursor move and an erase, with no composer glyph
* in it, so it marks activity without arming the idle confirmation.
*/
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
screen.text = text;
feed(session, '\x1b[2;1H\x1b[K');
}
/** Output, then quiet: the moment the idle confirmation reads the screen. */
function settle(session: Session): void {
for (let i = 0; i < 3; i++) {
feed(session, CODEX_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
return changes;
}
describe('Session.screenBlocked', () => {
afterEach(() => {
vi.useRealTimers();
});
it('raises once when the pane settles on an approval, and reads the session idle', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
expect(changes).toHaveLength(1);
expect(changes[0].blocked).toBe(true);
// The frame it was read from rides along, as the approval card's context.
expect(changes[0].frame).toContain('Would you like to run the following command?');
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
settle(session);
expect(changes).toHaveLength(1);
});
it('calls a question dialog blocked and idle, not working', () => {
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
// field the probe called this pane busy for as long as the question stayed up.
vi.useFakeTimers();
const session = withFakePane({ text: fixture('question.txt') });
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
});
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
// what notices, and one read alone is not enough.
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
// Nothing keeps reading a screen with no dialog on it.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
expect(changes).toHaveLength(2);
});
it('does not flap on one read that misses a dialog still on screen', () => {
// A capture inside a full-frame repaint can come back without the footer. Clearing on
// it would raise again a moment later: a second alert, push and webhook for one dialog.
vi.useFakeTimers();
const dialog = fixture('exec-approval.txt');
const screen = { text: dialog as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
redraw(session, screen, dialog);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
expect(session.screenBlocked).toBe(true);
expect(changes).toHaveLength(1);
});
it('keeps its state through an unreadable capture, in either direction', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
settle(session);
// The pane moved, so the session looks, and tmux answers nothing it can read.
redraw(session, screen, null);
const before = screen.captures!;
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
expect(session.screenBlocked).toBe(true);
// ...and it is still watching: once the screen reads again without the dialog, it clears.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
// Each 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. It leaves the
// screen only through a repaint, which reaches the session as output, so a pane that
// drew nothing since the last reading that saw the dialog is still showing it.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
expect(session.screenBlocked).toBe(true);
const quietMs = 10 * 60_000;
const before = screen.captures!;
vi.advanceTimersByTime(quietMs);
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
// The answer repaints the pane, and the clear comes as fast as before.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
// tmux updates the pane before it sends this client the repaint, so a capture can see
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
// is right only while the last reading SAW the dialog: once one reading missed it,
// waiting for output could leave the alert up for the whole safety-net interval.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(false);
});
it('raises again for the next dialog after a clear', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
screen.text = fixture('edit-approval.txt');
settle(session);
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
});
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
vi.useFakeTimers();
for (const mode of ['claude', 'gemini'] as const) {
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked, mode).toBe(false);
expect(changes, mode).toHaveLength(0);
}
});
it('drops the state when a new pane starts, announcing it', () => {
vi.useFakeTimers();
const session = withFakePane({ text: fixture('trust-dialog.txt') });
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
(session as unknown as { _resetBuffers(): void })._resetBuffers();
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
});
});
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Working (5s • esc to interrupt)
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
› Ask Codex to do anything
GPT-6-Luna medium · /tmp/codex-scratch/repo · Request approval to create file Plan mode
? for shortcuts
@@ -0,0 +1,23 @@
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to
ask me which animal to write in it, with 3 options. Ask
only that one question.
■ Conversation interrupted - use /feedback if something went
wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to
cancel tab to add notes | enter to submit answer | esc
to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo Plan mode
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
■ Conversation interrupted - use /feedback if something went wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to cancel tab to add notes | enter to submit answer | esc to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo · Ask for animal choice Plan mode (shift+tab to cycle)
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
GPT-6-Luna default · /tmp/codex-scratch/repo
+50
View File
@@ -0,0 +1,50 @@
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
Would you like to make the following edits?
Description: Apply proposed file edits
Destination: /tmp/codex-scratch/repo/calc.py
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+14
View File
@@ -0,0 +1,14 @@
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with
write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
[… 21 lines] ^a view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,50 @@
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
› Run this exact multi-line shell command with escalated permissions (ask me for approval): a bash heredoc that writes 12 lines 'line 1' through 'line 12' into lines.txt, using cat > lines.txt with a
quoted heredoc, each line written out literally.
• I’ll request approval to run the quoted heredoc that writes the 12 literal lines to lines.txt.
• Running cat > lines.txt <<'EOF' …
+ Show details
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
line 11
line 12
EOF
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+30
View File
@@ -0,0 +1,30 @@
› Run: touch second.txt (needs app…
access outside the read only
sandbox. I’ll request approval to
run that exact command.
• Running touch second.txt
Would you like to run the
following command?
Environment: local
Reason: May I run `touch
second.txt` outside the read
only sandbox to create the
requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for
commands that start with `touch
second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to
cancel
+30
View File
@@ -0,0 +1,30 @@
outside the sandbox)
• Creating second.txt requires write access
outside the read only sandbox. I’ll
request approval to run that exact
command.
• Running touch second.txt
Would you like to run the following
command?
Environment: local
Reason: May I run `touch second.txt`
outside the read only sandbox to create
the requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands
that start with `touch second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Running touch created.txt
Would you like to run the following command?
Environment: local
Reason: May I run `touch created.txt` outside the read only sandbox to create the requested file?
$ touch created.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `touch created.txt` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
⣀⣤⣤⣤⣀⣀
⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀
⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄
⣀⣾⣿⣿⠃ ⣀⣴⣾⣿⣿⡿⠟⠋ ⣀⡀ ⠈⠙⢿⣿⣿⣧
⣀⣶⣿⣿⣿⣿⡇ ⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇
⣰⣿⣿⡿⢿⣿⣿⡇ ⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷
⢰⣿⣿⡟⠁⢸⣿⣿⡇ ⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀
⣾⣿⣿⠃ ⢸⣿⣿⡇ ⢸⣿⣿⠋⠁ ⠈⠙⢻⣿⣿⣿⣶⣤⡀ ⠹⢿⣿⣷⡄
⢸⣿⣿⣇ ⠸⣿⣿⣷⣦⣼⣿⣿ ⢸⣿⣿⠻⢿⣿⣿⡇ ⠘⣿⣿⣿
⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀ ⢀⣠⣼⣿⣿ ⣿⣿⡇ ⣿⣿⣿⠇
⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿ ⣿⣿⡇ ⣠⣿⣿⣿
⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿ ⣿⣿⣷⣴⣿⣿⣿⠃
⠘⣿⣿⣷ ⠈⠛⢿⣿⣿⣿⠿⠋⠉ ⣠⣴⣿⣿⣿ ⣿⣿⣿⣿⣿⠟⠁
⠻⣿⣿⣿⣤⣀ ⠈⠉ ⢀⣤⣶⣿⣿⣿⠿⠟⠁ ⣰⣿⣿⡟⠋⠁
⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁ ⣠⣼⣿⣿⡟
⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏
⠈⠙⠛⠿⠿⠿⠿⠛⠉
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Implement this plan?
› 1. Yes, implement this plan Switch to Default and start coding
2. Yes, clear context and implement Start a fresh thread (current context: 2% used)
3. No, stay in Plan mode Continue planning with the model
enter select · esc back
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Select Model and Effort
› 1. GPT-6-Luna (current) Fast and affordable model for easier tasks.
2. GPT-5.6-Terra Older balanced model for straightforward work.
3. GPT-5.6-Luna Older fast and efficient model.
enter select · esc back
@@ -0,0 +1,50 @@
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
• Running curl -sS -o /dev/null -w '%{http_code}' https://example.com
Would you like to run the following command?
Environment: local
Reason: May I rerun curl with network access to fetch https://example.com and report its HTTP status?
$ curl -sS -o /dev/null -w '%{http_code}' https://example.com
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `curl -sS -o /dev/null -w '%{http_code}' https://example.com` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
Update Model Permissions
1. Ask for approval Read and edit workspace files and run commands, with approval required for internet access or edits outside the workspace
2. Approve for me Only ask for actions detected as potentially unsafe
3. Full Access Use with caution: Codex can edit files outside this workspace and access the internet without approval
› 4. Read Only (current) Read workspace files, with approval required for edits or internet access
enter select · esc back
+49
View File
@@ -0,0 +1,49 @@
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/2 (2 unanswered)
What should the greeting file be named?
› 1. greeting.txt (Recommended) Use a clear, conventional text filename.
2. README greeting Add the greeting as a separate README-style Markdown file.
3. hello.txt Use a short, friendly filename.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,49 @@
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 2/2 (1 unanswered)
What greeting should it contain?
› 1. Hello, world! (Recommended) Use a short, familiar English greeting.
2. Welcome! Use a concise welcoming message.
3. Good morning! Use a time-specific greeting.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
Question 1/1 (1 unanswered)
Which animal should I write in animal.txt?
› 1. Cat Write “cat” in the file.
2. Dog Write “dog” in the file.
3. Elephant Write “elephant” in the file.
4. None of the above Optionally, add details in notes (tab)
› abc
tab or esc to clear notes | enter to submit answer
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
Question 1/1 (1 unanswered)
Which animal should I write in animal.txt?
› 1. Cat Write “cat” in the file.
2. Dog Write “dog” in the file.
3. Elephant Write “elephant” in the file.
4. None of the above Optionally, add details in notes (tab)
› Add notes
tab or esc to clear notes | enter to submit answer
@@ -0,0 +1,49 @@
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/1 (1 unanswered)
Which language should the greeting file use?
1. English (Recommended) Write a simple greeting in English.
› 2. French Write a simple greeting in French.
3. Spanish Write a simple greeting in Spanish.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | esc to interrupt
+29
View File
@@ -0,0 +1,29 @@
• Explored · 1 failed
└ List rg --files -g color.txt -g
AGENTS.md (exit 1)
+ Show details
Question 1/1 (1 unanswered)
Which color should I write in
`color.txt`?
› 1. Blue Write the
color
blue.
2. Green Write the
color
green.
3. Red Write the
color red.
4. None of the Optionally
above , add
details in
notes
(tab)
tab to add notes
enter to submit answer
esc to interrupt
+29
View File
@@ -0,0 +1,29 @@
› Run: touch second.txt (needs approval outside the sandbo…
■ Conversation interrupted - use /feedback if something went
wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your
question tool which color to write in it (offer 3
options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
Question 1/1 (1 unanswered)
Which color should I write in `color.txt`?
› 1. Blue Write the color blue.
2. Green Write the color green.
3. Red Write the color red.
4. None of the above Optionally, add details in notes
(tab)
tab to add notes | enter to submit answer
esc to interrupt
+49
View File
@@ -0,0 +1,49 @@
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/1 (1 unanswered)
Which language should the greeting file use?
› 1. English (Recommended) Write a simple greeting in English.
2. French Write a simple greeting in French.
3. Spanish Write a simple greeting in Spanish.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | esc to interrupt
+50
View File
@@ -0,0 +1,50 @@
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
Select a review preset
› 1. Review against a base branch (PR Style)
2. Review uncommitted changes
3. Review a commit
4. Custom review instructions
enter select · esc back
+49
View File
@@ -0,0 +1,49 @@
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
› /plan switch to Plan mode
› /plan
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Model changed to gpt-6-luna default for Default mode.
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
└ Tip: NEW: Network proxy can now be enabled from /experimental. Restart Codex after enabling it.
Worked for 1s • 4:31 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+17
View File
@@ -0,0 +1,17 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read,
edit, and run files here, subject to
your permission settings. Folder
settings can run code automatically,
even without a model request.
Continue only if you trust these
files. Your trust decision will be
saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
+11
View File
@@ -0,0 +1,11 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings can run code automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Working (1s • esc to interrupt)
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · ⠧
? for shortcuts
+54 -1
View File
@@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { raiseScreenBlocked } from '../../src/web/screen-blocked.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
@@ -455,7 +456,6 @@ describe('approval routes', () => {
});
});
describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
let harness: RouteTestHarness;
let session: MockSession;
@@ -505,6 +505,59 @@ describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
});
});
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
let harness: RouteTestHarness;
let session: MockSession;
const noopDeps = {
broadcast: () => {},
broadcastSessionStateDebounced: () => {},
sendPushNotifications: () => {},
};
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('lists the item, and the page-load sweep keeps it', async () => {
raiseScreenBlocked(session as never, noopDeps, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({ sessionId: SESSION_ID, kind: 'permission', answerInTerminal: true });
});
it('refuses an answer with the terminal-only error, not "cannot have pending approvals"', async () => {
// The hooks gate would say a codex session cannot have an item at all, which is false
// about the one the caller holds; the alert-only check runs first.
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/^Codex approvals must be answered in the terminal/);
}
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('can still be dismissed', async () => {
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
expect(res.statusCode).toBe(200);
expect(await listApprovals(harness)).toHaveLength(0);
});
});
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
+34 -1
View File
@@ -24,7 +24,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
import { MAX_WAIT_MS, MIN_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
@@ -315,6 +315,39 @@ describe('POST /api/sessions/:id/input with wait', () => {
expect(body.data.status).toBe('busy');
});
it('a duplicate on a codex pane showing a dialog answers blocked, as GET .../wait does', async () => {
// A screen-read dialog is a level, not an edge: the original delivery may have raised
// it before the caller's connection dropped, and the retry must not wait out its
// timeout for an edge that already passed.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
writeBuffer: string[];
};
session.mode = 'codex';
await send(app, { input: 'first', clientId: 'c-codex', seq: 1 });
session.status = 'idle';
session.screenBlocked = true;
const replay = await send(app, {
input: 'first',
clientId: 'c-codex',
seq: 1,
wait: 'blocked',
waitTimeout: MIN_WAIT_MS,
});
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: true, timedOut: false });
// ...and only to a caller that asked for it: such a pane is idle too.
const idle = await send(app, { input: 'first', clientId: 'c-codex', seq: 1, wait: 'idle' });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();
+75
View File
@@ -34,6 +34,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
@@ -463,6 +464,80 @@ describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
});
});
describe('GET /api/sessions/:id/wait: blocked read off a codex screen', () => {
/** The listener deps the screen-blocked raise reaches; nothing else matters here. */
function stubDeps() {
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => undefined),
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION_ID, 'session_ended');
});
it('accepts until=blocked for codex, whose dialogs are read off its screen', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=1000` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['blocked']);
});
it('answers blocked at once for a dialog already on screen, and only to a caller who asked', async () => {
// A hook-raised blocked is an edge; this one is a level the server is still reading,
// so a caller who asks after the dialog appeared does not wait out its timeout.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
};
session.mode = 'codex';
session.status = 'idle';
session.screenBlocked = true;
const asked = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,blocked` });
expect(asked.json().data.wait).toMatchObject({ signal: 'blocked', immediate: true });
// Such a pane is idle too, and a caller that asked for idle alone keeps getting idle.
const idle = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('wakes a parked until=blocked waiter when the session raises a screen-read dialog', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
session.emit('blockedChanged', true, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const body = (await pending).json();
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: false, timedOut: false });
expect(approvalInbox.getForSession(SESSION_ID)?.answerInTerminal).toBe(true);
// ...and the dialog leaving the screen resolves the item it opened.
session.emit('blockedChanged', false, null);
expect(approvalInbox.getForSession(SESSION_ID)).toBeUndefined();
});
it('still refuses until=stop for codex: nothing on a screen says a turn ended', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop,blocked` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Signal(s) stop never fire for codex');
});
});
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
// Session parks a dead PTY at status 'idle' and the object survives in the map,
+110
View File
@@ -0,0 +1,110 @@
/**
* What the server raises for a dialog read off a hook-less CLI's screen (codex), and what
* clears it: `web/screen-blocked.ts`. The session side, and the codex captures it reads,
* are in `codex-blocked-detection.test.ts`.
*
* The contract: exactly what a Claude `permission_prompt` hook raises (the `blocked` wait
* signal, an Approvals Inbox item, the `hook:permission_prompt` broadcast, push and
* webhook, a run-summary row), but ALERT-ONLY, since the dialog was read from text the
* agent writes: the item is `answerInTerminal`, push carries no `approvalId`, and the
* inbox never resolves or answers it on its own.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { raiseScreenBlocked, clearScreenBlocked, screenBlockedMessage } from '../src/web/screen-blocked.js';
import { approvalInbox } from '../src/web/approval-inbox.js';
import { sessionWaits } from '../src/web/session-wait-registry.js';
import { SseEvent } from '../src/web/sse-events.js';
import type { SessionMode } from '../src/types.js';
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
describe('raiseScreenBlocked / clearScreenBlocked', () => {
const SESSION = { id: 'screen-blocked-test', name: 'w9-scratch', mode: 'codex' as SessionMode };
function deps() {
const recordHookEvent = vi.fn();
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => ({ recordHookEvent })),
recordHookEvent,
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION.id, 'session_ended');
sessionWaits.cancelAll(SESSION.id);
});
it('raises what a permission_prompt hook raises, alert-only', async () => {
const d = deps();
const waiting = sessionWaits.waitForSignal(SESSION.id, { until: ['blocked'], timeoutMs: 5000 });
const itemId = raiseScreenBlocked(SESSION, d, fixture('exec-approval.txt'));
expect((await waiting).signal).toBe('blocked');
const item = approvalInbox.getForSession(SESSION.id)!;
expect(item.id).toBe(itemId);
expect(item.kind).toBe('permission');
expect(item.answerInTerminal).toBe(true);
expect(item.message).toBe('Codex is waiting for your answer in the terminal');
// The frame is the card's context, and nothing parsed it into answer buttons.
expect(item.context).toContain('Press enter to confirm or esc to cancel');
expect(item.options).toBeUndefined();
expect(d.broadcast).toHaveBeenCalledWith(
SseEvent.HookPermissionPrompt,
expect.objectContaining({ sessionId: SESSION.id, source: 'screen', approvalId: itemId })
);
expect(d.broadcastSessionStateDebounced).toHaveBeenCalledWith(SESSION.id);
// Push and webhook get the message and NO approvalId, so no Approve/Deny buttons.
const [event, push] = d.sendPushNotifications.mock.calls[0] as [string, Record<string, unknown>];
expect(event).toBe(SseEvent.HookPermissionPrompt);
expect(push).toMatchObject({ sessionId: SESSION.id, sessionName: 'w9-scratch', source: 'screen' });
expect(push.message).toBe(screenBlockedMessage('codex'));
expect(push.approvalId).toBeUndefined();
expect(d.recordHookEvent).toHaveBeenCalledWith('permission_prompt', expect.objectContaining({ source: 'screen' }));
});
it('never steers a keystroke: the inbox item cannot be answered', () => {
raiseScreenBlocked(SESSION, deps(), fixture('exec-approval.txt'));
const item = approvalInbox.getForSession(SESSION.id)!;
// No capture callback, so the inbox's Claude-shaped sweep never resolves it either:
// the session owns its lifetime.
expect(approvalInbox.verifyStillAnswerable(item.id)).toBe(true);
approvalInbox.resolveIfDialogGone(SESSION.id);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(item.id);
});
it('clears by resolving the item it opened, and only that one', () => {
const resolved: string[] = [];
const prev = approvalInbox.onResolved;
approvalInbox.onResolved = (info) => resolved.push(`${info.id}:${info.resolution}`);
try {
const itemId = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
expect(resolved).toContain(`${itemId}:resolved_in_terminal`);
// An item it did not open (here a newer one) is left alone.
const newer = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
clearScreenBlocked(SESSION.id, null);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
} finally {
approvalInbox.onResolved = prev;
}
});
it('raises nothing for a session whose CLI reports blocked through hooks', () => {
const d = deps();
expect(raiseScreenBlocked({ ...SESSION, mode: 'claude' }, d, null)).toBeNull();
expect(d.broadcast).not.toHaveBeenCalled();
expect(d.sendPushNotifications).not.toHaveBeenCalled();
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
});
});
+36 -1
View File
@@ -11,6 +11,7 @@ import {
SessionWaitRegistry,
WaitCapacityError,
hooksAvailableForMode,
blockedSignalAvailable,
parseWaitSignals,
resolveWaitSignals,
signalForStatus,
@@ -100,6 +101,26 @@ describe('signalForStatus', () => {
});
});
describe('blockedSignalAvailable', () => {
it('is true where hooks report it, and for a CLI that declares a blockedLine (codex)', () => {
expect(blockedSignalAvailable('claude')).toBe(true);
expect(blockedSignalAvailable('codex')).toBe(true);
// Not keyed on hooks: codex has none, and its screen-read dialogs are still a source.
expect(hooksAvailableForMode('codex')).toBe(false);
});
it('is false for a CLI with neither (shell, gemini, opencode)', () => {
for (const mode of ['shell', 'gemini', 'opencode'] as const) {
expect(blockedSignalAvailable(mode)).toBe(false);
}
});
it('follows the per-session deepseek bridge, which is its only source', () => {
expect(blockedSignalAvailable('deepseek')).toBe(true);
expect(blockedSignalAvailable('deepseek', { deepSeekStatusReporting: false })).toBe(false);
});
});
describe('hooksAvailableForMode', () => {
it('is true only for claude', () => {
expect(hooksAvailableForMode('claude')).toBe(true);
@@ -144,8 +165,22 @@ describe('resolveWaitSignals', () => {
const result = resolveWaitSignals('stop', codex);
expect(result.until).toEqual([]);
expect(result.error).toContain('codex');
expect(resolveWaitSignals('blocked', codex).error).toBeTruthy();
expect(resolveWaitSignals('idle,stop', codex).error).toBeTruthy();
// gemini declares no blockedLine, so nothing can ever raise `blocked` for it.
expect(resolveWaitSignals('blocked', { mode: 'gemini' as SessionMode }).error).toBeTruthy();
});
it('accepts `blocked` on codex, whose dialogs are read off the screen, but still not `stop`', () => {
// capabilities.workDetect.blockedLine gives codex a source for `blocked`; nothing on a
// screen says a turn ended, so `stop` keeps its 400.
expect(resolveWaitSignals('blocked', codex)).toEqual({ until: ['blocked'], error: null });
expect(resolveWaitSignals('idle,blocked,exit', codex)).toEqual({ until: ['idle', 'blocked', 'exit'], error: null });
const stop = resolveWaitSignals('stop,blocked', codex);
expect(stop.until).toEqual([]);
expect(stop.error).toContain('stop');
expect(stop.error).not.toContain('Signal(s) stop, blocked');
// The suggestion names what does fire for codex.
expect(stop.error).toContain('idle, blocked or exit');
});
it('drops hook-only signals from the DEFAULT set instead of erroring', () => {
+18
View File
@@ -97,6 +97,24 @@ describe('sendPushNotifications -> webhook (the headless case)', () => {
expect(sendNotification).not.toHaveBeenCalled();
});
it('carries a screen-read dialog (codex) with its own message, and a hook message stays out', async () => {
// web/screen-blocked.ts sends the CLI's own label, built server-side, never the
// dialog's text. A Claude hook's `message` keeps the body it always had.
const { send } = await headlessServer({});
await send('hook:permission_prompt', {
sessionId: 's3',
sessionName: 'w3-app',
source: 'screen',
message: 'Codex is waiting for your answer in the terminal',
});
await send('hook:permission_prompt', {
sessionId: 's4',
sessionName: 'w4-app',
message: 'Claude needs your permission to use Bash',
});
expect(got.map((g) => g.body)).toEqual(['[w3-app] Codex is waiting for your answer in the terminal', '[w4-app]']);
});
it('dedupes an immediate repeat of the same event for the same session', async () => {
const { send } = await headlessServer({});
await send('hook:idle_prompt', { sessionId: 's1', sessionName: 'w1-app' });