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