From 9b3030f9a1cc93c9ed8ae51ffb8d0d2424770048 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Wed, 14 Oct 2026 05:19:14 +0200 Subject: [PATCH] docs(codex): blocked dialogs read off the screen Document what the two previous commits changed for users and for whoever adds the field to another CLI: - docs/cli-registry.md: the `blockedLine`/`blockedLines` fields, how codex's pattern was measured, and the three rules any declaring CLI keeps (end on chrome only the CLI draws, alert only; raise on one reading, clear on two; hooks win). - docs/architecture-invariants.md: a "Screen-read blocked dialogs (codex)" entry, plus the wait-signal, idle-detection, watching and Approvals Inbox entries it changes. - docs/api-reference.md, docs/extending-codeman.md and the wiki (Agent CLIs, Notifications and Approvals, Hooks, HTTP API, Driving Codeman From An Agent): `blocked` now fires for codex, `stop` still does not, codex approvals are alert-only cards answered in the terminal. - CLAUDE.md: `stop` and `blocked` are no longer one rule, the config-regex list gains `blockedLine`, and a short pointer that a codex dialog match is only ever an alert. - The codeman skill's signal table and verbs note, mirrored into plugins/codeman by scripts/sync-plugin.mjs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 8 ++- docs/api-reference.md | 51 +++++++++----- docs/architecture-invariants.md | 18 ++++- docs/cli-registry.md | 66 +++++++++++++++++-- docs/extending-codeman.md | 13 ++-- docs/wiki/Agent-CLIs.md | 15 ++++- docs/wiki/Driving-Codeman-From-An-Agent.md | 11 ++-- docs/wiki/HTTP-API.md | 7 +- docs/wiki/Hooks-And-Integrations.md | 7 +- docs/wiki/Notifications-And-Approvals.md | 14 ++-- .../skills/codeman/reference/endpoints.md | 2 +- .../codeman/skills/codeman/reference/verbs.md | 4 +- skills/codeman/reference/endpoints.md | 2 +- skills/codeman/reference/verbs.md | 4 +- 14 files changed, 168 insertions(+), 54 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adb38400..e7710033 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is a CLIENT: no new route, no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` @@ -142,6 +142,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ⚠️ **A turn that ENDED waiting for its own workers is working, not idle.** Claude closes such a turn with `✻ Waiting for 1 dynamic workflow to finish` (background agents / ultracode) and resumes by itself; `capabilities.workDetect.awaitingLine` makes the idle probe count it as work. ⚠️ Claude never redraws that row, so it stays on screen after the workers finish: test it ONLY as the newest column-0 row above the composer (`isAwaitingWorkers()`), never pane-wide and never on the stream. → [architecture-invariants#idle-detection-composer-glyph-and-working-line](docs/architecture-invariants.md#idle-detection-composer-glyph-and-working-line) +⚠️ **A codex dialog is BLOCKED, read off the screen, and only ever an ALERT.** Codex has no hook for its approval, question and trust dialogs, so `capabilities.workDetect.blockedLine` names their chrome and `web/screen-blocked.ts` raises what a `permission_prompt` hook raises, as an `answerInTerminal` item. ⚠️ Never key a keystroke off that match (the agent writes most of the pane), and never hand its item a `capture` (the inbox's sweep is Claude-shaped; the session's 2 s re-read owns the clear). A blocked screen beats the working line. → [architecture-invariants#screen-read-blocked-dialogs-codex](docs/architecture-invariants.md#screen-read-blocked-dialogs-codex) + ⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`. **An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit) @@ -168,7 +170,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) @@ -192,7 +194,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Reboot restore** (`src/reboot-restore.ts` pure + `web/reboot-restore-registry.ts` + `routes/reboot-restore-routes.ts` + `reboot-restore-ui.js`): after a host reboot kills every pane, Codeman holds an IN-MEMORY plan of the destroyed sessions and a banner offers to rebuild them. ⚠️ The heuristic only decides whether to ASK. ⚠️ Rebuild is TAKE-then-build (entries leave the plan before the first `await`, single-flighted per owner). ⚠️ Re-check grant, workspace and already-live at click time, reading already-live FRESH per entry; key confinement on the entry's OWNER, never the caller. ⚠️ Rebuilt sessions come back disarmed: no respawn/Ralph, `rearmAutoResumeSchedule: false`, and pass `nameSource` through. ⚠️ Undo a failed rebuild with `discardPartiallyBuiltSession()`, NEVER `cleanupSession()`. Claude-mode only, never remote/docker. Tests: `test/reboot-restore.test.ts`. → [architecture-invariants#reboot-restore](docs/architecture-invariants.md#reboot-restore) -**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory, claude-only queue fed by `/api/hook-event`, at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox) +**Approvals Inbox** (`approvalsInboxEnabled`, SYNCED, default OFF; the store and answer endpoints run regardless): `web/approval-inbox.ts` is an in-memory queue fed by `/api/hook-event` (answerable items claude-only; DeepSeek's and codex's screen-read ones are alert-only `answerInTerminal`), at most ONE item per session, answered via `POST /api/approvals/:id/answer` through `writeViaMux` (menu answers never carry `\r`). ⚠️ Accept `option` digits ONLY if they match options parsed from a fresh RE-CAPTURE of the pane; a dialog no longer on screen is a 409. ⚠️ Resolve permission/question items only via the pane-verified `verifyStillAnswerable()`; the heuristic `working` signal alone may resolve `idle` items only. ⚠️ `applyCapture()` is ADD-ONLY for `options`. ⚠️ Viewing ACKNOWLEDGES an idle item (never resolves it) and only a human selection does: app-made selections pass `selectSession(id, { auto: true })`; `_ackDelivery` spends the IDLE alert only. ⚠️ `handleInit` seeds tab alerts from `GET /api/approvals` REGARDLESS of the setting. → [architecture-invariants#approvals-inbox](docs/architecture-invariants.md#approvals-inbox) **Read My Mind intent profiles** (`readMyMindEnabled`, SYNCED, default OFF; `docs/readmymind-plan.md`): per-CASE profiles (goals + recent prompts) keyed by owner + realpath(workingDir), captured from the transcript (`transcript:user_prompt`), never the input paths; the listener must stay inside `startTranscriptWatcher()`'s `if (!watcher)` block. Store `src/intent-store.ts` → `intents.json`, ⚠️ written 0600 tmp+rename and never fed to `/api/search` (prompts carry secrets). Routes in `readmymind-routes.ts` (ownership via `findSessionOrFail` WITH `req`); predictor = pure `readmymind-context.ts` + IO in `readmymind-collectors.ts` + `readmymind-predictor.ts`, claude-only, one in flight per session (409). ⚠️ Suggestions render via value/`textContent` ONLY and nothing auto-sends, ever. Frontend `readmymind-ui.js`. → [architecture-invariants#read-my-mind-intent-profiles](docs/architecture-invariants.md#read-my-mind-intent-profiles) diff --git a/docs/api-reference.md b/docs/api-reference.md index db7ac4d2..a7f6bc0a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -205,15 +205,17 @@ auto-retried by several clients (silently doubling the polling load), `504` is w a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status` / `limitPaused`. Reserve error handling for the four codes in the table below. -**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude -Code hooks, and no other mode installs them: `shell` runs no agent, and the external -CLIs (`opencode`, `codex`, `gemini`, `antigravity`, `pi`) render their own TUIs and post -no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are -accepted, and of those only `exit` is dependable: see the caveats under -[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked` -**explicitly** on such a session is a -`400`; omitting `until` never fails, the server just drops them from the default set -and echoes the narrowed set back as `wait.until`. Three more places hooks can go +**2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for +`codex`.** Both come from Claude Code hooks, and no other mode installs them: `shell` +runs no agent, and the external CLIs render their own TUIs and post no hooks +(`deepseek`'s status bridge reports the same two, see its own docs). The one exception +is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off +its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode +only `idle`, `working` and `exit` are accepted, and of those only `exit` is +dependable: see the caveats under [Signals](#signals) before building on `idle`. +Requesting a signal the session can never emit **explicitly** is a `400` (`stop` on +`codex` included); omitting `until` never fails, the server just drops them from the +default set and echoes the narrowed set back as `wait.until`. Three more places hooks can go missing even in `claude` mode: a **Docker case** needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, since a container cannot reach a loopback-bound Codeman (without it, only `idle` / `working` / `exit` work); a **remote-SSH @@ -239,8 +241,8 @@ contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send |--------|--------|--------------------| | `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `shell`: **once only**, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization) | | `working` | the session's own `working` event | `claude` only in practice (spinner and work-keyword detection are Claude output formats) | -| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` only | -| `blocked` | a `permission_prompt` or `elicitation_dialog` hook | `claude` only, and rarer than it looks: see below | +| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only | +| `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below | | `exit` | no process is behind the session | every mode | `stop` is the signal to orchestrate on where it exists; `idle` is a heuristic @@ -277,6 +279,17 @@ asking the user a question. So `until=stop,blocked,exit` is a reasonable belt on long turn, but a worker that never comes back is far more likely to be working than blocked, and polling `blocked` alone will sit at its timeout. +**`blocked` on `codex` is read off the screen**, because codex has no hooks and is +launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set. +A codex pane showing a command, edit or network approval, a question (`request_user_input`) +or the startup trust prompt raises `blocked` (plus the red alert, push and webhook a Claude +`permission_prompt` raises, alert-only: answer it in the terminal), and the session reads +`idle` while it is up. Unlike a hook, this is a level the server keeps reading, so a wait +for `blocked` that arrives while the dialog is already up answers at once +(`immediate: true`); a wait that did not ask for `blocked` is unaffected. It clears 2 to 4 s +after the dialog leaves the screen. `stop` stays a `400` on codex: nothing on a screen says a +turn ended. + ⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell session emits its one `idle` at startup and then stays `status: "idle"` forever, whatever the pane is doing, so it never emits a *transition*. Since send-and-wait @@ -520,7 +533,7 @@ read the timeout as "the worker is wedged" and kill a session that was working f | `errorCode` | HTTP | When | |-------------|------|------| -| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that installs no hooks (the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | +| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | | `NOT_FOUND` | 404 | no such session, or one this caller does not own | | `SESSION_BUSY` | 409 | this session's waiter cap is full | | `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem | @@ -744,15 +757,18 @@ non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, -AskUserQuestion questions, idle prompts). Claude-mode sessions only; items are -in-memory (a server restart drops them; the next prompt re-fires the hook). +AskUserQuestion questions, idle prompts). Answerable items come from Claude-mode +sessions only; DeepSeek Harness (its status bridge) and Codex (dialogs read off its +screen) open **alert-only** items with `answerInTerminal: true`. Items are +in-memory (a server restart drops them; the next prompt re-fires the hook, and a +codex dialog still on screen is read again after the pane re-attaches). Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md). - `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first, ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId, sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?, toolSummary?, message?, cwd?, context?, options?: {n, label}[], - acknowledgedAt? }`. `context` is the ANSI-stripped visible pane frame; + acknowledgedAt?, answerInTerminal? }`. `context` is the ANSI-stripped visible pane frame; `options` is present only when the dialog's numbered choices parsed confidently; `acknowledgedAt` marks an item a human has already looked at (see `/viewed` below) and tells clients not to re-arm its tab alert. Listing @@ -766,8 +782,9 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md). (sends the digit; accepted only when `n` is among the item's parsed `options`), or `{ action: 'text', text }` (idle prompts only; submits the line as a prompt). `404 NOT_FOUND` when the item is no longer pending, - `409 CONFLICT` when the dialog left the screen or another actor answered - first, `422 OPERATION_FAILED` when the session refused input. + `400 INVALID_INPUT` for an `answerInTerminal` item (` approvals must be + answered in the terminal`), `409 CONFLICT` when the dialog left the screen or + another actor answered first, `422 OPERATION_FAILED` when the session refused input. - `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes. - `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId, acknowledged: itemId | null }`. Marks the session's pending **idle** item as diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf..d2674ec5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -141,7 +141,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. **Signal availability is decided by MODE, not by `isExternalCliMode()`.** `stop` and `blocked` come from Claude Code hooks, so `hooksAvailableForMode()` is true only for `claude`: `shell` is not an external-CLI mode but installs no hooks either, so `until=stop` on a shell session is a guaranteed unresolvable wait dressed up as a timeout. The behavior split is deliberate and must survive refactors: an EXPLICIT request for an unavailable signal is a 400 naming the mode, while the DEFAULT set (`stop,idle,exit`) silently drops them and echoes the narrowed set back as `wait.until`, because omitting the parameter must never 400. Signal quality is not uniform either: `stop` is definitive (Claude Code says the turn is over), `idle` is inferred from output stabilization plus prompt detection and can flap mid-turn when a spinner pauses, which is why `stop` is the documented default to orchestrate on and `idle` is the fallback for sessions that emit no hooks. ⚠️ **`idle` being ACCEPTED for a mode does not mean it ever FIRES there.** `startShell()` emits exactly one `idle` on a 500ms readiness timer and nothing afterwards, so a shell session sits at `status:'idle'` no matter what its pane is doing; since send-and-wait and `fresh=1` both require a TRANSITION, both can only time out on a shell worker (measured: a default `wait` on `sleep 4` burned its full 25s). The ❯-prompt and spinner detection that drives the real `working`/`idle` cycle is Claude's output format, so hook-less modes synchronize with `wait-output` markers or `exit`, and the docs must say so rather than listing `idle` as "available" and letting the reader infer it is usable. ⚠️ **Signals are edge-triggered with no history**, and that is a real orchestration limit: a signal that fires while no waiter is registered is gone, unobservable by any later wait variant (`until=stop` after the turn ended just times out, `fresh` or not — measured, R2-A). Documented client patterns must therefore register the waiter before the event can fire (send-and-wait) or gather on latched `wait-output` markers with `from=buffer`; the skill's fan-out flow was rewritten accordingly, and "fire-and-forget N prompts, then gather signal-waits sequentially" must never be documented again. The durable fix, a server-side latched last-signal-per-turn, is deferred with Part 3 of `docs/agent-control-plan.md`. ⚠️ Relatedly, the route corrects liveness that `SessionStatus` cannot express: `currentSignalFor()` answers `exit` whenever `pid === null` (exited, detached, or created-and-never-started) **or the mux pane is dead**, because `Session` parks a DEAD PTY at `_status = 'idle'` and trusting the status would answer the default wait `{signal:'idle', immediate:true}` for a crashed worker while `until=exit` blocked forever on an event that already happened. ⚠️ **`pid` alone cannot carry liveness for a tmux-backed session**: that pid is the local `tmux attach` CLIENT, so a worker exiting inside its pane leaves `pane_dead=1` with the client alive and `pid` never goes null — the `pid === null` branch is unreachable in the normal configuration (unit tests exercise it because `MockSession` sets `pid` by hand; only a live instance showed the gap). Liveness is therefore probed at the mux layer: `workerIsDead()` consults `mux.isPaneDead(muxName)` with a ~750 ms per-pane cache, ONLY on blocking waits (measured: 0 tmux execs across 100 non-wait input POSTs — the browser hot path pays nothing), plus a refcounted 3 s `watchForDeadWorker` interval so a worker dying while a wait is parked resolves it in ~3 s instead of burning the timeout. The probe fails SAFE by construction ("cannot tell" is never "dead": non-mux sessions, a missing or throwing `isPaneDead`, all return false). On send-and-wait, a "successful" `send-keys` into a dead pane additionally overrides `delivered` to `false` and rolls the dedup seq back (`undoOnFailure`), because the bytes went nowhere and a retry against a restarted worker must not be refused as a duplicate. The cost is that a just-created session reads as `exit`, which the wire docs must spell out as "not started yet"; the fix lives at the route rather than in `signalForStatus()` because the registry deliberately holds no `Session` reference. ⚠️ Two `claude`-mode cases still lose hooks for reasons outside the registry: a Docker case cannot reach a loopback-bound Codeman without `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, and a remote-SSH case runs the agent on another host whose hooks may never reach this server. Bounds are env-overridable (`CODEMAN_WAIT_MAX_MS`, `CODEMAN_WAIT_DEFAULT_MS`, `CODEMAN_WAIT_MAX_PER_SESSION`, `CODEMAN_WAIT_MAX_PER_OWNER`, `CODEMAN_WAIT_MAX_TOTAL`, `CODEMAN_WAIT_BUFFER_SCAN_BYTES`) and each is clamped to a hard bound so a typo degrades to the default instead of disabling the protection; they are internal tuning knobs like the rest of `src/config/`, NOT part of the SemVer-covered env-var surface in `versioning-policy.md`. Tests: `test/session-wait-registry.test.ts`, `test/routes/session-wait-routes.test.ts`, `test/routes/session-wait-output-routes.test.ts`, `test/routes/session-input-wait.test.ts`. -Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. +Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). **Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default OFF). ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route. @@ -278,6 +278,8 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ A fourth, optional `workDetect` field, `awaitingLine`, makes a turn that ENDED waiting for workers the CLI started count as WORKING: with background agents or an ultracode workflow still running at turn end, Claude closes the turn with `✻ Waiting for 1 dynamic workflow to finish` instead of `✻ Brewed for 1m 18s`, then resumes by itself when they report back, so the pane is quiet with the composer up and every other signal read it as idle (reported 2026-09-28 on a live 2.1.283 session). `_probePaneWorking()` ORs it into the same capture. ⚠️ It must never be searched across the pane like `workingLine`: Claude renders the row from a `useState` snapshot taken at turn end and never redraws it, so the words stay on screen after the workers finish and a pane-wide match would pin the tab busy until they scroll off. `isAwaitingWorkers()` (pure, `session-activity.ts`) finds the composer (the LAST row carrying `promptGlyph`, bare or boxed), walks up at most `AWAITING_SEARCH_ROWS` past blank, box-drawn and indented rows, and tests only the first column-0 row. ⚠️ Keep the pattern anchored on column 0 (`^✻`): Claude's own rows start there and the agent's prose never does, which is what stops an agent from holding its own session busy. It is deliberately NOT tested on the PTY stream, where redraws of the stale row would re-mark working. Tests: `test/session-awaiting-workers.test.ts`. +⚠️ A fifth, optional `workDetect` field, `blockedLine` (plus `blockedLines`), marks a pane showing a dialog the CLI is blocked on, and it WINS over the working line in the same capture (codex's question dialog carries `esc to interrupt`, its working line, in its own key hints). See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). + ### The watching signal (a quiet pane that is not waiting for you) **A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ A capture that FAILS clears the label (and announces the change) rather than keeping the last one: a stale label opens the next idle prompt already acknowledged, so keeping it would turn a failed `capture-pane` into a missed alert, while clearing it costs at most an alert the next readable capture takes back. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen. @@ -286,7 +288,17 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t ⚠️ **A chip that waits on a HUMAN must never count as watching**: Claude's Artifact comment monitor (an agent that published a page and hears nothing until somebody comments) is refused for the whole row by a `^`-anchored negative lookahead keyed on "Artifact" alone, so a footer cut off mid-chip is refused too, and the `^` is what stops the engine retrying past the chip and reporting a shell beside it. -**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both — its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'` — no hook event from a codex session reaches `notePrompt()`, so a forged label costs a wrong badge and nothing else, and a CLI that gains hook signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces). +**⚠️ The label is pane-derived, so the window and the anchor are a trust boundary, not formatting.** An agent that gets its own text matched silences its own alert. Two things prevent it, and BOTH belong to whoever adds a pattern for a new CLI: the window must cover only rows that CLI draws, and the pattern must anchor on chrome only that CLI can produce. Claude satisfies both: its chip is the LAST row, so the default window of one row excludes even the status line directly above it, whose content comes from a `statusLine` command a bypassed session can write into its own `.claude/settings.json`. Codex does not: its row sits above the composer, and the slot it occupies holds the last row of the TRANSCRIPT whenever no terminal is running, so matching the complete row raises the bar without closing it. What contains that is `hooks: 'none'`: no IDLE prompt from a codex session reaches `notePrompt()` (its screen-read dialogs open PERMISSION items, which a watching label never acknowledges), so a forged label costs a wrong badge and nothing else, and a CLI that gains idle signals must not keep a pattern that soft. The label is also ANSI-stripped and capped (`MAX_WATCHING_LABEL_CHARS`) at the source, and every interpolation of it into markup goes through `escapeHtml()`, since a config-supplied capture group decides what it holds. Tests: `test/session-watching.test.ts` (the label and both CLI patterns), `test/watching-no-alert.test.ts` (the negative claim across all four surfaces). + +### Screen-read blocked dialogs (codex) + +**Codex is the one CLI Codeman launches with its approvals ON by default, and it has no hook to say it stopped for one** (`hooks: 'none'`), so a codex session parked on "Would you like to run the following command?" read as plainly idle, and one parked on a question as WORKING. `capabilities.workDetect.blockedLine` (optional, per CLI, plus `blockedLines`, 1 to 24, default 1) names the dialog's chrome; `_probePaneWorking()` reads it off the same capture the idle confirmation takes (`_paneShowsBlocked()` → `isBlockedScreen()`, pure, `session-activity.ts`: the last `blockedLines` non-blank rows joined with `\n`, tested once, so a pattern can demand several markers and end on `$`), and `Session` emits `blockedChanged`. `session-listener-wiring.ts` hands that to `web/screen-blocked.ts`, which raises EXACTLY what a Claude `permission_prompt` hook raises (`sessionWaits.notifySignal('blocked')`, an Approvals Inbox item, the `hook:permission_prompt` broadcast with `source: 'screen'`, push and webhook, a run-summary row), so the tab alert, phone overview, TUI and every other consumer needed no second code path; clearing resolves the item it opened, and `approval:resolved` clears every client. + +⚠️ **ALERT ONLY, never a keystroke.** The text is pane-derived and the agent writes most of the pane, so the pattern must end on chrome only the CLI draws at the FOOT of the screen, where a dialog replaces the composer (codex's: a `› N.` option row, then its key-hint row as the last row, allowing for that row to wrap; a transcript that quotes every word still has codex's column-0 composer row below it). The item is `answerInTerminal` (#610's shape), the answer route refuses it, push carries no `approvalId`, and Codeman never answers such a dialog itself, so a forged match costs a false alert and nothing more. The message every surface shows is built from the CLI's registry label, never from the screen (a question's text and a command preview are the agent's to choose). + +⚠️ **Raise on one reading, clear on two, and keep reading while it is up.** The raise needs no timer: a dialog is drawn at the END of a burst of output, so the settle probe (pane quiet, then capture) sees it. The clear does: answering resumes the turn, a busy pane never reaches the settle path again until the turn ends, and the red alert outlived the dialog by minutes, so `_blockedRecheckTimer` re-reads the screen every `BLOCKED_RECHECK_MS` (2 s, one `capture-pane`, only while a dialog is up; cleared in `_clearAllTimers()`). It clears after `BLOCKED_CLEAR_READS` (2) readable captures in a row without the dialog, because one capture can land inside a full-frame repaint, and a clear followed by a re-raise sends the alert, push and webhook twice for one dialog. An unreadable capture (`null`) changes nothing in either direction. A new pane (`_resetBuffers()`) and a PTY exit clear it, announced, while the listeners are still attached. The working probe decides blocked-over-working from THIS capture, never from `_screenBlocked`, which lags a vanished dialog by design. + +⚠️ **Hooks win.** `raiseScreenBlocked()` does nothing for a session whose CLI passes `hooksAvailableForMode()`, so a CLI that declared both could never alert twice for one dialog; and the inbox item gets no `capture` callback (only the frame, as context), because the inbox's option parser and staleness sweep are shaped for Claude's dialogs and the SESSION owns this item's lifetime. ⚠️ Codex's `Implement this plan?` after a plan turn is deliberately not matched: its `enter select · esc back` footer is the same as `/model`, `/permissions` and `/review`, menus the user opened at the keyboard, and the plan turn has ended there. Measured fixtures (codex-cli 0.162.0, 2026-10-14, widths 200 to 36): `test/fixtures/codex-dialogs/`. Tests: `test/codex-blocked-detection.test.ts` (the pattern and the session state), `test/screen-blocked.test.ts` (what the server raises), the codex blocks of `test/routes/session-wait-routes.test.ts` and `test/routes/approval-routes.test.ts`. ### Workspace-trust dialog auto-accept @@ -615,7 +627,7 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ### Approvals Inbox -**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). +**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), in-memory; answerable items are claude-mode only, and the DeepSeek status bridge and codex's screen-read dialogs (`web/screen-blocked.ts`) open ALERT-ONLY items (`answerInTerminal`: the answer route refuses them BEFORE its hooks gate, push carries no `approvalId`, the card and overview strip say "answer in the terminal"). Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). diff --git a/docs/cli-registry.md b/docs/cli-registry.md index ebc4d443..2d54fb87 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -46,9 +46,10 @@ interface CliEntry { launch: CliLaunch; // the structured argv template env: CliEnv; // exports, tmux setenv keys, the env-override allowlist capabilities: CliCapabilities; // what every call site reads instead of the id - // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine? } - // — how this CLI's pane shows work, work it started in the background, and a turn - // that ended waiting for workers it will resume from + // .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?, + // blockedLine?, blockedLines? } + // (how this CLI's pane shows work, work it started in the background, a turn + // that ended waiting for workers it will resume from, and a dialog it is blocked on) // .modelDetect?: { screenLine, screenLines? } // (where this CLI's own chrome names the model it runs: SessionState.displayModel) // .launchDefaults?: { [launchParam]: settingsKey } @@ -62,7 +63,7 @@ interface CliEntry { ### Regexes that come from config -Five capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine` and `capabilities.modelDetect.screenLine`. All five go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. +Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing. `workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused. @@ -118,9 +119,60 @@ 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. It clears only after + two readable captures in a row without the dialog (`BLOCKED_CLEAR_READS`), so a capture + taken mid-repaint cannot clear and re-raise it (a second alert, push and webhook for one + dialog). An unreadable capture changes nothing in either direction. A blocked screen wins + over the working line in the same capture. +- Hooks win: a session whose CLI reports blocked itself (`hooksAvailableForMode()`) gets + nothing from the screen, so declaring both can never alert twice for one dialog. + +A `GET /api/sessions/:id/wait?until=blocked` that arrives while such a dialog is already up is +answered at once (`immediate: true`): unlike a hook's edge, this is a level the server is +still reading. A caller that asked only for `idle` keeps getting `idle`. ### Three capabilities that must stay independent diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index fb866056..3c01c162 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -220,8 +220,9 @@ whether your integration works, and the last one is what actually bites: to die in the field. - **`wait.timeoutMs`** is the timeout after server-side clamping (600 s ceiling by default). Read it rather than assuming you got what you asked for. -- **`stop` and `blocked` only exist for `claude` sessions**, and on a `shell` session - even `idle` fires only once at startup, so send-and-wait there can only time out. +- **`stop` only exists for `claude` (and `deepseek`) sessions, `blocked` also for + `codex`** (read off its screen), and on a `shell` session even `idle` fires only once + at startup, so send-and-wait there can only time out. See the Gotchas below. ⚠️ **There is no readiness signal, and skipping readiness is the failure that looks @@ -438,9 +439,11 @@ Every one of these has cost somebody real time. a multi-word match is unreliable there. Match one short space-free token, ideally one you printed yourself, and keep it out of the typed line (your own keystrokes echo into the stream). -- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini`, - `antigravity` or `pi` sessions.** They come from Claude Code hooks, which no other mode - installs, so only `idle`, `working` and `exit` exist there. Asking for them +- **`stop` and `blocked` never fire for `shell`, `opencode`, `gemini`, `antigravity` or + `pi` sessions, and `stop` never for `codex`.** They come from Claude Code hooks, which + no other mode installs, so only `idle`, `working` and `exit` exist there. The exception + is `blocked` on `codex`, which Codeman raises from the approval, question and trust + dialogs it reads off codex's screen. Asking for them explicitly is a `400`; omitting `until` is safe, since the server drops them from the default set and echoes what it actually waited on as `wait.until`. Even in `claude` mode, a Docker case needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for hooks to diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 037fe1e1..24b828e6 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -72,12 +72,12 @@ output. The other CLIs expose no equivalent. | Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser | | Auto-resume when a usage limit resets | Yes | No | | Plan usage chip | Yes | No | -| Approvals Inbox | Yes | DeepSeek yes; others no | +| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no | | Read My Mind | Yes | No | | Ralph loop and its task tracker | Yes | No | | Subagent and team windows | Yes | No | | Model, effort, advisor, and ultracode controls | Yes | No | -| `stop` and `blocked` wait signals | Yes | DeepSeek yes; elsewhere 400 if you ask for them explicitly | +| `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly | | The bundled agent skill | Yes | No | Everything that makes a session a session works everywhere. What is Claude-only is mostly @@ -153,6 +153,17 @@ Two behaviours that are deliberate and worth knowing: does; before 1.26.1 every Codex session reported idle for its whole life. Codex conversations also appear in Past Sessions and can be resumed, and on phones the keyboard bar grows `⇧←` / `⇧→` for Codex's queued-message editing and prompt stack. +- **A dialog waiting for you turns the tab red.** Codex is launched with its approvals on + unless you tick the bypass option, and it has no hooks to say it stopped, so Codeman reads + its dialogs off the screen: a command, edit or network approval, a question it asks you + (Plan mode), and the "Trust this folder?" prompt when it starts in a new folder. Each one + raises the same red "needs you" alert a Claude permission prompt does, with the desktop + notification, web push, webhook, the phone overview's NEEDS YOU row and an Approvals Inbox + card, and the alert clears a few seconds after you answer. Answer in the terminal: Codeman + never answers a Codex dialog for you, so the inbox card and the push carry no Approve or + Deny buttons. While such a dialog is up the session shows as idle (a question used to show + as working), and agents can wait on it with the `blocked` signal. "Implement this plan?" + at the end of a plan is not counted, since the turn has ended there. ### Gemini diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 4d62f05b..6bfa9803 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -180,10 +180,13 @@ Read these before writing any code. Each one has cost somebody an afternoon. 5. **Wait instead of polling, and a timeout is not an error.** The wait endpoints answer `200` with `wait.timedOut: true`. Loop over short waits rather than one long call, because tunnels cut idle connections. -6. **Only `claude` and `deepseek` sessions emit `stop` and `blocked`.** Claude's come from - Claude Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and - the other external CLIs accept only `idle`, `working`, and `exit`; asking for `stop` - explicitly there is a `400`, while omitting `until` is always safe. On a shell session +6. **Only `claude` and `deepseek` sessions emit `stop`; `blocked` also fires for `codex`.** + Claude's come from Claude Code hooks, DeepSeek's from the harness reporting its state to + Codeman, and Codex's `blocked` from Codeman reading its approval, question and trust + dialogs off the screen (a wait that asks for `blocked` while such a dialog is already up + answers at once). Shell and the other external CLIs accept only `idle`, `working`, and + `exit`; asking for `stop` explicitly there (codex included) is a `400`, while omitting + `until` is always safe. On a shell session `idle` fires **once at startup and never again**, so synchronize hook-less sessions with an output marker instead. 7. **Nothing reports "ready", so wait for it explicitly.** A new session answers diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index b4fb3cae..d13fddd2 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -142,9 +142,10 @@ Three semantics that break callers who assume otherwise: `wait-output` matches a **literal substring, never a regex.** That is deliberate: no regex means no catastrophic backtracking on attacker-influenced output. -Only `claude` and `deepseek` sessions emit `stop` and `blocked`: Claude's come from Claude -Code hooks, DeepSeek's from the harness reporting its state to Codeman. Shell and the other -external CLI sessions accept `idle`, `working`, and `exit`. +Only `claude` and `deepseek` sessions emit `stop`: Claude's come from Claude Code hooks, +DeepSeek's from the harness reporting its state to Codeman. `blocked` fires for those two +and for `codex`, whose approval, question and trust dialogs Codeman reads off the screen. +Shell and the other external CLI sessions accept `idle`, `working`, and `exit`. ## SSE diff --git a/docs/wiki/Hooks-And-Integrations.md b/docs/wiki/Hooks-And-Integrations.md index 832e0be6..c22a02ee 100644 --- a/docs/wiki/Hooks-And-Integrations.md +++ b/docs/wiki/Hooks-And-Integrations.md @@ -22,8 +22,11 @@ terminal into something that can notify you. This is why several Codeman features are Claude-only. The one partial exception is DeepSeek Harness, whose terminal front door reports idle, working and blocked to Codeman over the harness's own supervisor contract, so it gets the hook-driven surfaces without any hook -file. The other CLIs have no equivalent, so for them Codeman watches terminal output, which -reveals that something happened but not what it was. +file. Codex gets one of them from its screen: Codeman reads its approval, question and +trust dialogs off the pane and raises the same red "needs you" alert a `permission_prompt` +hook raises (see [Agent CLIs](Agent-CLIs#codex)). The other CLIs have no equivalent, so for +them Codeman watches terminal output, which reveals that something happened but not what it +was. ### How hooks get installed diff --git a/docs/wiki/Notifications-And-Approvals.md b/docs/wiki/Notifications-And-Approvals.md index fcc495a9..01b3a60c 100644 --- a/docs/wiki/Notifications-And-Approvals.md +++ b/docs/wiki/Notifications-And-Approvals.md @@ -34,8 +34,11 @@ reloading the dashboard while a permission dialog is blocking a session does not with a normal-looking tab. For Claude sessions, these come from Claude Code's hooks and are precise about *why* the -session stopped; DeepSeek Harness sessions report the same states themselves. For the other -CLIs there are no hooks, so you get the coarser output-based signal. +session stopped; DeepSeek Harness sessions report the same states themselves. Codex has no +hooks, but its dialogs are distinctive enough to read off the screen, so a Codex session +waiting on an approval, a question or the "Trust this folder?" prompt turns red too, and +clears a few seconds after you answer. For the other CLIs there are no hooks, so you get the +coarser output-based signal. ## Window title and OS notifications @@ -109,7 +112,9 @@ Behaviour worth knowing: ## The Approvals Inbox **Opt-in, off by default. Claude sessions, plus DeepSeek Harness sessions, whose terminal -front door reports its prompts to Codeman.** +front door reports its prompts to Codeman, and Codex sessions, whose dialogs Codeman reads +off the screen.** DeepSeek and Codex cards are alerts: they say "Answer in the terminal" and +carry no answer buttons, because Codeman cannot verify a keystroke into those dialogs. One queue of every prompt currently waiting on a human, across all your sessions, answerable in place. When you have eight workers running, this is the difference between checking eight @@ -214,7 +219,8 @@ from the lock screen. - **iOS needs the home screen install.** A Safari tab will never receive push. - **The bell is invisible at zero.** That is deliberate, not a broken setting. - **Approvals need real signals.** They are built on hook events, which Claude emits and - DeepSeek Harness reports itself; the other CLIs do neither. + DeepSeek Harness reports itself, and on Codex's dialogs as its screen shows them; the + other CLIs give neither. - **A stale menu answer is refused, not sent.** If you answer a card for a dialog that has since gone away, Codeman declines rather than typing a digit into the composer. diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 62c6eafd..64ad7c29 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -635,7 +635,7 @@ Three bounded long-polls. Shared semantics: | `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode | | `working` | session started producing output | every mode | | `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only | -| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude` only | +| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer them in the terminal, never through the inbox) | | `exit` | PTY exited or session deleted | every mode | ⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index db9d7d7e..690cdbf3 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -377,7 +377,9 @@ only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **an `deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a real end-of-turn signal rather than a guess. On `shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a -400, and lifecycle transitions there are coarse (a short shell command may emit **no** +400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval, +question or trust dialog is up; a wait for it answers at once if one already is), and +lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. ⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting: diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 62c6eafd..64ad7c29 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -635,7 +635,7 @@ Three bounded long-polls. Shared semantics: | `idle` | output stabilized + prompt detected, heuristic, can flap mid-turn | every mode | | `working` | session started producing output | every mode | | `stop` | Claude Code `stop` hook, the definitive end-of-turn | `claude` only | -| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude` only | +| `blocked` | `permission_prompt` / `elicitation_dialog` hook, the worker needs an answer | `claude`; also `codex`, whose approval, question and trust dialogs Codeman reads off its screen (answer them in the terminal, never through the inbox) | | `exit` | PTY exited or session deleted | every mode | ⚠️ **`claude` mode is necessary for `stop`/`blocked`, not sufficient. The real diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index db9d7d7e..690cdbf3 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -377,7 +377,9 @@ only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **an `deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a real end-of-turn signal rather than a guess. On `shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a -400, and lifecycle transitions there are coarse (a short shell command may emit **no** +400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval, +question or trust dialog is up; a wait for it answers at once if one already is), and +lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. ⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting: