diff --git a/CLAUDE.md b/CLAUDE.md index adb384009..e77100339 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `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 `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) diff --git a/README.md b/README.md index f496be073..816f8b575 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 38dd8e25e..aee92d874 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 就是避开它的顺序。 ### 常用配方 diff --git a/docs/api-reference.md b/docs/api-reference.md index db7ac4d27..a7f6bc0aa 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 (` 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 diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf5..76b414d2b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -141,7 +141,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. **Signal availability is decided by MODE, not by `isExternalCliMode()`.** `stop` and `blocked` come from Claude Code hooks, so `hooksAvailableForMode()` is true only for `claude`: `shell` is not an external-CLI mode but installs no hooks either, so `until=stop` on a shell session is a guaranteed unresolvable wait dressed up as a timeout. The behavior split is deliberate and must survive refactors: an EXPLICIT request for an unavailable signal is a 400 naming the mode, while the DEFAULT set (`stop,idle,exit`) silently drops them and echoes the narrowed set back as `wait.until`, because omitting the parameter must never 400. Signal quality is not uniform either: `stop` is definitive (Claude Code says the turn is over), `idle` is inferred from output stabilization plus prompt detection and can flap mid-turn when a spinner pauses, which is why `stop` is the documented default to orchestrate on and `idle` is the fallback for sessions that emit no hooks. ⚠️ **`idle` being ACCEPTED for a mode does not mean it ever FIRES there.** `startShell()` emits exactly one `idle` on a 500ms readiness timer and nothing afterwards, so a shell session sits at `status:'idle'` no matter what its pane is doing; since send-and-wait and `fresh=1` both require a TRANSITION, both can only time out on a shell worker (measured: a default `wait` on `sleep 4` burned its full 25s). The ❯-prompt and spinner detection that drives the real `working`/`idle` cycle is Claude's output format, so hook-less modes synchronize with `wait-output` markers or `exit`, and the docs must say so rather than listing `idle` as "available" and letting the reader infer it is usable. ⚠️ **Signals are edge-triggered with no history**, and that is a real orchestration limit: a signal that fires while no waiter is registered is gone, unobservable by any later wait variant (`until=stop` after the turn ended just times out, `fresh` or not — measured, R2-A). Documented client patterns must therefore register the waiter before the event can fire (send-and-wait) or gather on latched `wait-output` markers with `from=buffer`; the skill's fan-out flow was rewritten accordingly, and "fire-and-forget N prompts, then gather signal-waits sequentially" must never be documented again. The durable fix, a server-side latched last-signal-per-turn, is deferred with Part 3 of `docs/agent-control-plan.md`. ⚠️ Relatedly, the route corrects liveness that `SessionStatus` cannot express: `currentSignalFor()` answers `exit` whenever `pid === null` (exited, detached, or created-and-never-started) **or the mux pane is dead**, because `Session` parks a DEAD PTY at `_status = 'idle'` and trusting the status would answer the default wait `{signal:'idle', immediate:true}` for a crashed worker while `until=exit` blocked forever on an event that already happened. ⚠️ **`pid` alone cannot carry liveness for a tmux-backed session**: that pid is the local `tmux attach` CLIENT, so a worker exiting inside its pane leaves `pane_dead=1` with the client alive and `pid` never goes null — the `pid === null` branch is unreachable in the normal configuration (unit tests exercise it because `MockSession` sets `pid` by hand; only a live instance showed the gap). Liveness is therefore probed at the mux layer: `workerIsDead()` consults `mux.isPaneDead(muxName)` with a ~750 ms per-pane cache, ONLY on blocking waits (measured: 0 tmux execs across 100 non-wait input POSTs — the browser hot path pays nothing), plus a refcounted 3 s `watchForDeadWorker` interval so a worker dying while a wait is parked resolves it in ~3 s instead of burning the timeout. The probe fails SAFE by construction ("cannot tell" is never "dead": non-mux sessions, a missing or throwing `isPaneDead`, all return false). On send-and-wait, a "successful" `send-keys` into a dead pane additionally overrides `delivered` to `false` and rolls the dedup seq back (`undoOnFailure`), because the bytes went nowhere and a retry against a restarted worker must not be refused as a duplicate. The cost is that a just-created session reads as `exit`, which the wire docs must spell out as "not started yet"; the fix lives at the route rather than in `signalForStatus()` because the registry deliberately holds no `Session` reference. ⚠️ Two `claude`-mode cases still lose hooks for reasons outside the registry: a Docker case cannot reach a loopback-bound Codeman without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, and a remote-SSH case runs the agent on another host whose hooks may never reach this server. Bounds are env-overridable (`CODEMAN_WAIT_MAX_MS`, `CODEMAN_WAIT_DEFAULT_MS`, `CODEMAN_WAIT_MAX_PER_SESSION`, `CODEMAN_WAIT_MAX_PER_OWNER`, `CODEMAN_WAIT_MAX_TOTAL`, `CODEMAN_WAIT_BUFFER_SCAN_BYTES`) and each is clamped to a hard bound so a typo degrades to the default instead of disabling the protection; they are internal tuning knobs like the rest of `src/config/`, NOT part of the SemVer-covered env-var surface in `versioning-policy.md`. Tests: `test/session-wait-registry.test.ts`, `test/routes/session-wait-routes.test.ts`, `test/routes/session-wait-output-routes.test.ts`, `test/routes/session-input-wait.test.ts`. -Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. +Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). **Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route. @@ -278,6 +278,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ A fourth, optional `workDetect` field, `awaitingLine`, makes a turn that ENDED waiting for workers the CLI started count as WORKING: with background agents or an ultracode workflow still running at turn end, Claude closes the turn with `✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when they report back, so the pane is quiet with the composer up and every other signal read it as idle (reported 2026-09-28 on a live 2.1.283 session). `_probePaneWorking()` ORs it into the same capture. ⚠️ It must never be searched across the pane like `workingLine`: Claude renders the row from a `useState` snapshot taken at turn end and never redraws it, so the words stay on screen after the workers finish and a pane-wide match would pin the tab busy until they scroll off. `isAwaitingWorkers()` (pure, `session-activity.ts`) finds the composer (the LAST row carrying `promptGlyph`, bare or boxed), walks up at most `AWAITING_SEARCH_ROWS` past blank, box-drawn and indented rows, and tests only the first column-0 row. ⚠️ Keep the pattern anchored on column 0 (`^✻`): Claude's own rows start there and the agent's prose never does, which is what stops an agent from holding its own session busy. It is deliberately NOT tested on the PTY stream, where redraws of the stale row would re-mark working. Tests: `test/session-awaiting-workers.test.ts`. +⚠️ A fifth, optional `workDetect` field, `blockedLine` (plus `blockedLines`), marks a pane showing a dialog the CLI is blocked on, and it WINS over the working line in the same capture (codex's question dialog carries `esc to interrupt`, its working line, in its own key hints). See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). + ### The watching signal (a quiet pane that is not waiting for you) **A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ A capture that FAILS clears the label (and announces the change) rather than keeping the last one: a stale label opens the next idle prompt already acknowledged, so keeping it would turn a failed `capture-pane` into a missed alert, while clearing it costs at most an alert the next readable capture takes back. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen. @@ -286,7 +288,17 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ **A chip that waits on a HUMAN must never count as watching**: Claude's Artifact comment monitor (an agent that published a page and hears nothing until somebody comments) is refused for the whole row by a `^`-anchored negative lookahead keyed on "Artifact" alone, so a footer cut off mid-chip is refused too, and the `^` is what stops the engine retrying past the chip and reporting a shell beside it. -**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both — its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'` — no hook event from a codex session reaches `notePrompt()`, so a forged label costs a wrong badge and nothing else, and a CLI that gains hook signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces). +**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both: its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'`: no IDLE prompt from a codex session reaches `notePrompt()` (its screen-read dialogs open PERMISSION items, which a watching label never acknowledges), so a forged label costs a wrong badge and nothing else, and a CLI that gains idle signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces). + +### Screen-read blocked dialogs (codex) + +**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 codex session parked on "Would you like to run the following command?" read as plainly idle, and one parked on a question as WORKING. `capabilities.workDetect.blockedLine` (optional, per CLI, plus `blockedLines`, 1 to 24, default 1) names the dialog's chrome; `_probePaneWorking()` reads it off the same capture the idle confirmation takes (`_paneShowsBlocked()` → `isBlockedScreen()`, pure, `session-activity.ts`: the last `blockedLines` non-blank rows joined with `\n`, tested once, so a pattern can demand several markers and end on `$`), and `Session` emits `blockedChanged`. `session-listener-wiring.ts` hands that to `web/screen-blocked.ts`, which raises EXACTLY what a Claude `permission_prompt` hook raises (`sessionWaits.notifySignal('blocked')`, an Approvals Inbox item, the `hook:permission_prompt` broadcast with `source: 'screen'`, push and webhook, a run-summary row), so the tab alert, phone overview, TUI and every other consumer needed no second code path; clearing resolves the item it opened, and `approval:resolved` clears every client. + +⚠️ **ALERT ONLY, never a keystroke.** 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 (codex's: a `› N.` option row, then its key-hint row as the last row, allowing for that row to wrap; a transcript that quotes every word still has codex's column-0 composer row below it). The item is `answerInTerminal` (#610's shape), the answer route refuses it, push carries no `approvalId`, and Codeman never answers such a dialog itself, so a forged match costs a false alert and nothing more. The message every surface shows is built from the CLI's registry label, never from the screen (a question's text and a command preview are the agent's to choose). + +⚠️ **Raise on one reading, clear on two, and keep reading while it is up.** The raise needs no timer: a dialog is drawn at the END of a burst of output, so the settle probe (pane quiet, then capture) sees it. The clear does: answering resumes the turn, a busy pane never reaches the settle path again until the turn ends, and the red alert outlived the dialog by minutes, so `_blockedRecheckTimer` re-reads the screen every `BLOCKED_RECHECK_MS` (2 s, one `capture-pane`, only while a dialog is up; cleared in `_clearAllTimers()`). ⚠️ That read is a synchronous exec (about 8 ms of blocked event loop, measured 2026-10-14) and a dialog can sit unanswered overnight, so the timer SKIPS it while the pane is quiet: a dialog leaves the screen only through a repaint, which reaches the session as PTY output, so nothing drawn since the last reading that saw the dialog (`_lastActivityAt < _blockedSeenAt`) means it is still up; a safety-net read still runs every `BLOCKED_QUIET_RECHECK_MS` (30 s). Never once a reading has MISSED the dialog (`_blockedGoneReads > 0`): tmux updates the pane before it sends this client the repaint, and the confirming read must not wait for output. It clears after `BLOCKED_CLEAR_READS` (2) readable captures in a row without the dialog, because one capture can land inside a full-frame repaint, and a clear followed by a re-raise sends the alert, push and webhook twice for one dialog. An unreadable capture (`null`) changes nothing in either direction. A new pane (`_resetBuffers()`) and a PTY exit clear it, announced, while the listeners are still attached. The working probe decides blocked-over-working from THIS capture, never from `_screenBlocked`, which lags a vanished dialog by design. + +⚠️ **Hooks win.** `raiseScreenBlocked()` does nothing for a session whose CLI passes `hooksAvailableForMode()`, so a CLI that declared both could never alert twice for one dialog; and the inbox item gets no `capture` callback (only the 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. ⚠️ Codex's `Implement this plan?` after a plan turn is deliberately not matched: its `enter select · esc back` footer is the same as `/model`, `/permissions` and `/review`, menus the user opened at the keyboard, and the plan turn has ended there. Measured fixtures (codex-cli 0.162.0, 2026-10-14, widths 200 to 36): `test/fixtures/codex-dialogs/`. Tests: `test/codex-blocked-detection.test.ts` (the pattern and the session state), `test/screen-blocked.test.ts` (what the server raises), the codex blocks of `test/routes/session-wait-routes.test.ts` and `test/routes/approval-routes.test.ts`. ### Workspace-trust dialog auto-accept @@ -615,7 +627,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ### Approvals Inbox -**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). +**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). diff --git a/docs/cli-registry.md b/docs/cli-registry.md index ebc4d443e..445bb43ce 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -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 diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index fb866056f..3c01c162f 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -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 diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 037fe1e19..24b828e67 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -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 diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 4d62f05be..6bfa98039 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -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 diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index b4fb3cae7..d13fddd22 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -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 diff --git a/docs/wiki/Hooks-And-Integrations.md b/docs/wiki/Hooks-And-Integrations.md index 832e0be60..c22a02eed 100644 --- a/docs/wiki/Hooks-And-Integrations.md +++ b/docs/wiki/Hooks-And-Integrations.md @@ -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 diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md index fcc495a9c..01b3a60ce 100644 --- a/docs/wiki/Notifications-And-Approvals.md +++ b/docs/wiki/Notifications-And-Approvals.md @@ -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. diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 62c6eafd9..64ad7c299 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -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 diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md index 352d4e00b..b518d93e6 100644 --- a/plugins/codeman/skills/codeman/reference/recipes.md +++ b/plugins/codeman/skills/codeman/reference/recipes.md @@ -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 diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index db9d7d7ef..690cdbf32 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -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: diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 62c6eafd9..64ad7c299 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -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 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 352d4e00b..b518d93e6 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -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 diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index db9d7d7ef..690cdbf32 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -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: diff --git a/src/cli-agent.ts b/src/cli-agent.ts index dca898820..505a5af31 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -914,7 +914,7 @@ export function registerAgentCommands(program: Command): Command { ) .option( '-u, --until ', - '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 ', diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 8bdcc281c..056abab6a 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -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() }) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 3075e36fd..55933c7c0 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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, diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 51eab76ef..9df6754eb 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -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 diff --git a/src/mcp-server.ts b/src/mcp-server.ts index aaf942b5e..c79b2b4ae 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -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: { diff --git a/src/session-activity.ts b/src/session-activity.ts index 599e96629..5b6d11405 100644 --- a/src/session-activity.ts +++ b/src/session-activity.ts @@ -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 diff --git a/src/session.ts b/src/session.ts index 5a2bb6775..829c81b33 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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) { diff --git a/src/web/approval-inbox.ts b/src/web/approval-inbox.ts index 8326e5003..c691fc985 100644 --- a/src/web/approval-inbox.ts +++ b/src/web/approval-inbox.ts @@ -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); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index e9cb509bd..1617fd26e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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): " · ", // and where the model came from when the CLI did not report it. The harness diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 7705b086c..892f1b3b7 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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) { diff --git a/src/web/routes/approval-routes.ts b/src/web/routes/approval-routes.ts index 283e1a804..39e928395 100644 --- a/src/web/routes/approval-routes.ts +++ b/src/web/routes/approval-routes.ts @@ -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 diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc5533..fe718fa78 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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 { diff --git a/src/web/screen-blocked.ts b/src/web/screen-blocked.ts new file mode 100644 index 000000000..b3bf45791 --- /dev/null +++ b/src/web/screen-blocked.ts @@ -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): void; + getRunSummaryTracker?( + sessionId: string + ): { recordHookEvent(event: string, data?: Record): 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'); +} diff --git a/src/web/server.ts b/src/web/server.ts index 2b11b4b38..503a99ac6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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; } diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 6f773698e..e719847d5 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -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); diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index e734927d7..3a3b9b834 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -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(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS); + const unsupported = new Set(); + 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 }; diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index b7020b9d2..3c1df40d7 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -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; diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 156d315bf..97855ee90 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -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).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).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).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).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', () => { diff --git a/test/codex-blocked-detection.test.ts b/test/codex-blocked-detection.test.ts new file mode 100644 index 000000000..86addd6ab --- /dev/null +++ b/test/codex-blocked-detection.test.ts @@ -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[0]>['mux']; + return new Session({ + workingDir: '/tmp', + mode, + mux, + muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() }, + } as ConstructorParameters[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]); + }); +}); diff --git a/test/fixtures/codex-dialogs/after-approve.txt b/test/fixtures/codex-dialogs/after-approve.txt new file mode 100644 index 000000000..1ed7bfcb3 --- /dev/null +++ b/test/fixtures/codex-dialogs/after-approve.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/after-deny.txt b/test/fixtures/codex-dialogs/after-deny.txt new file mode 100644 index 000000000..f2d00094c --- /dev/null +++ b/test/fixtures/codex-dialogs/after-deny.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/after-question.txt b/test/fixtures/codex-dialogs/after-question.txt new file mode 100644 index 000000000..12153d67e --- /dev/null +++ b/test/fixtures/codex-dialogs/after-question.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/composer-typed-dialog-text-w60.txt b/test/fixtures/codex-dialogs/composer-typed-dialog-text-w60.txt new file mode 100644 index 000000000..b44cad8b5 --- /dev/null +++ b/test/fixtures/codex-dialogs/composer-typed-dialog-text-w60.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/composer-typed-dialog-text.txt b/test/fixtures/codex-dialogs/composer-typed-dialog-text.txt new file mode 100644 index 000000000..d69127374 --- /dev/null +++ b/test/fixtures/codex-dialogs/composer-typed-dialog-text.txt @@ -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) diff --git a/test/fixtures/codex-dialogs/composer-typed.txt b/test/fixtures/codex-dialogs/composer-typed.txt new file mode 100644 index 000000000..f16a6bad0 --- /dev/null +++ b/test/fixtures/codex-dialogs/composer-typed.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/edit-approval.txt b/test/fixtures/codex-dialogs/edit-approval.txt new file mode 100644 index 000000000..c166f8e78 --- /dev/null +++ b/test/fixtures/codex-dialogs/edit-approval.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/exec-approval-60x14.txt b/test/fixtures/codex-dialogs/exec-approval-60x14.txt new file mode 100644 index 000000000..78a316d41 --- /dev/null +++ b/test/fixtures/codex-dialogs/exec-approval-60x14.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/exec-approval-long-command.txt b/test/fixtures/codex-dialogs/exec-approval-long-command.txt new file mode 100644 index 000000000..9eccf4f93 --- /dev/null +++ b/test/fixtures/codex-dialogs/exec-approval-long-command.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/exec-approval-w36.txt b/test/fixtures/codex-dialogs/exec-approval-w36.txt new file mode 100644 index 000000000..d2ba3e4ed --- /dev/null +++ b/test/fixtures/codex-dialogs/exec-approval-w36.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/exec-approval-w44.txt b/test/fixtures/codex-dialogs/exec-approval-w44.txt new file mode 100644 index 000000000..8ad0e8392 --- /dev/null +++ b/test/fixtures/codex-dialogs/exec-approval-w44.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/exec-approval.txt b/test/fixtures/codex-dialogs/exec-approval.txt new file mode 100644 index 000000000..85ac4baa7 --- /dev/null +++ b/test/fixtures/codex-dialogs/exec-approval.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/idle-start.txt b/test/fixtures/codex-dialogs/idle-start.txt new file mode 100644 index 000000000..631096801 --- /dev/null +++ b/test/fixtures/codex-dialogs/idle-start.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/implement-plan.txt b/test/fixtures/codex-dialogs/implement-plan.txt new file mode 100644 index 000000000..e2d767133 --- /dev/null +++ b/test/fixtures/codex-dialogs/implement-plan.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/model-popup.txt b/test/fixtures/codex-dialogs/model-popup.txt new file mode 100644 index 000000000..3353d114e --- /dev/null +++ b/test/fixtures/codex-dialogs/model-popup.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/network-escalation-approval.txt b/test/fixtures/codex-dialogs/network-escalation-approval.txt new file mode 100644 index 000000000..16256303f --- /dev/null +++ b/test/fixtures/codex-dialogs/network-escalation-approval.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/permissions-popup.txt b/test/fixtures/codex-dialogs/permissions-popup.txt new file mode 100644 index 000000000..22ba2b591 --- /dev/null +++ b/test/fixtures/codex-dialogs/permissions-popup.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-1of2.txt b/test/fixtures/codex-dialogs/question-1of2.txt new file mode 100644 index 000000000..d52374515 --- /dev/null +++ b/test/fixtures/codex-dialogs/question-1of2.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-2of2-submit-all.txt b/test/fixtures/codex-dialogs/question-2of2-submit-all.txt new file mode 100644 index 000000000..c5f6e8d19 --- /dev/null +++ b/test/fixtures/codex-dialogs/question-2of2-submit-all.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-notes-typed.txt b/test/fixtures/codex-dialogs/question-notes-typed.txt new file mode 100644 index 000000000..7fdcc2941 --- /dev/null +++ b/test/fixtures/codex-dialogs/question-notes-typed.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-notes.txt b/test/fixtures/codex-dialogs/question-notes.txt new file mode 100644 index 000000000..63bc767c0 --- /dev/null +++ b/test/fixtures/codex-dialogs/question-notes.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-option2-selected.txt b/test/fixtures/codex-dialogs/question-option2-selected.txt new file mode 100644 index 000000000..56759317c --- /dev/null +++ b/test/fixtures/codex-dialogs/question-option2-selected.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-w36.txt b/test/fixtures/codex-dialogs/question-w36.txt new file mode 100644 index 000000000..b58fea38f --- /dev/null +++ b/test/fixtures/codex-dialogs/question-w36.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question-w60.txt b/test/fixtures/codex-dialogs/question-w60.txt new file mode 100644 index 000000000..a9a194909 --- /dev/null +++ b/test/fixtures/codex-dialogs/question-w60.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/question.txt b/test/fixtures/codex-dialogs/question.txt new file mode 100644 index 000000000..99ec73ce0 --- /dev/null +++ b/test/fixtures/codex-dialogs/question.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/review-popup.txt b/test/fixtures/codex-dialogs/review-popup.txt new file mode 100644 index 000000000..4e9031d56 --- /dev/null +++ b/test/fixtures/codex-dialogs/review-popup.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/slash-popup.txt b/test/fixtures/codex-dialogs/slash-popup.txt new file mode 100644 index 000000000..ad0d0946c --- /dev/null +++ b/test/fixtures/codex-dialogs/slash-popup.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/transcript-quotes-dialog.txt b/test/fixtures/codex-dialogs/transcript-quotes-dialog.txt new file mode 100644 index 000000000..df4f6fdee --- /dev/null +++ b/test/fixtures/codex-dialogs/transcript-quotes-dialog.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/trust-dialog-w40.txt b/test/fixtures/codex-dialogs/trust-dialog-w40.txt new file mode 100644 index 000000000..2509b8b07 --- /dev/null +++ b/test/fixtures/codex-dialogs/trust-dialog-w40.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/trust-dialog.txt b/test/fixtures/codex-dialogs/trust-dialog.txt new file mode 100644 index 000000000..147360092 --- /dev/null +++ b/test/fixtures/codex-dialogs/trust-dialog.txt @@ -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 diff --git a/test/fixtures/codex-dialogs/working.txt b/test/fixtures/codex-dialogs/working.txt new file mode 100644 index 000000000..8839f6a10 --- /dev/null +++ b/test/fixtures/codex-dialogs/working.txt @@ -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 diff --git a/test/routes/approval-routes.test.ts b/test/routes/approval-routes.test.ts index 2e3acb89f..f265b0a6b 100644 --- a/test/routes/approval-routes.test.ts +++ b/test/routes/approval-routes.test.ts @@ -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 = {}; diff --git a/test/routes/session-input-wait.test.ts b/test/routes/session-input-wait.test.ts index 950804592..65ed2139c 100644 --- a/test/routes/session-input-wait.test.ts +++ b/test/routes/session-input-wait.test.ts @@ -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(); diff --git a/test/routes/session-wait-routes.test.ts b/test/routes/session-wait-routes.test.ts index 155efb97b..2d900def3 100644 --- a/test/routes/session-wait-routes.test.ts +++ b/test/routes/session-wait-routes.test.ts @@ -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, diff --git a/test/screen-blocked.test.ts b/test/screen-blocked.test.ts new file mode 100644 index 000000000..f4407a145 --- /dev/null +++ b/test/screen-blocked.test.ts @@ -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]; + 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(); + }); +}); diff --git a/test/session-wait-registry.test.ts b/test/session-wait-registry.test.ts index 46da51414..63a7b9ee2 100644 --- a/test/session-wait-registry.test.ts +++ b/test/session-wait-registry.test.ts @@ -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', () => { diff --git a/test/webhook-push-glue.test.ts b/test/webhook-push-glue.test.ts index 1e1263591..88c5e1302 100644 --- a/test/webhook-push-glue.test.ts +++ b/test/webhook-push-glue.test.ts @@ -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' });