diff --git a/.changeset/bounded-agent-coordination.md b/.changeset/bounded-agent-coordination.md index 1122ed67..1239817a 100644 --- a/.changeset/bounded-agent-coordination.md +++ b/.changeset/bounded-agent-coordination.md @@ -5,3 +5,5 @@ Add bounded agent inspection with expiring scoped cursors and explicit pane snapshots, plus staged local Codex input and single-use hash-confirmed submission receipts. Verify the exact composer, caret, lifecycle and input revision before sending Enter, and serialize dispatch against managed input and auxiliary key operations. Document the initial local Codex support and conversation-attribution limits in the API, CLI and packaged skill. Other modes retain labeled snapshots or refuse unverifiable guarded input. + +SubmitVerifier Enter re-presses now use the shared input boundary and update `lastSubmitAt` for each retry on every supported CLI. This keeps submission timestamps consistent with delivered Enter attempts while preventing a stale retry from bypassing a guarded submission. diff --git a/CLAUDE.md b/CLAUDE.md index bd2594ac..683084f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,9 +129,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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` +**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 an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked +`agent-session-routes.ts` module; the other verbs reuse existing routes, with 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` -**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; no new route). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. +**MCP server (`codeman mcp`, `src/mcp-server.ts`)**: a Model Context Protocol server on stdio for any MCP client, a CLIENT of the HTTP API like `codeman agent` (reuses its `httpRequest`, `inputRefusal`, `buildSendBody`; the MCP server adds no routes). Seven tools: `list_sessions`, `start_session`, `send_prompt`, `wait_for_session`, `read_output`, `interrupt_session`, `delete_session`. ⚠️ Dual-era: answers the legacy `initialize` handshake (2024-11-05 to 2025-11-25) AND the 2026-07-28 revision (per-request `_meta` with `protocolVersion` + `clientCapabilities`, `server/discover`, `resultType: "complete"` on every modern result); a modern `tools/list`/`server/discover` MUST carry `ttlMs` + `cacheScope` (Claude Code 2.1.296 drops every tool without them: measured). ⚠️ stdout is protocol only; logs go to stderr. ⚠️ The URL comes from `--url`/`CODEMAN_API_URL`, never a guess, and unconfigured it still lists tools (registries introspect) while every call answers how to configure it. ⚠️ Prompt/interrupt/delete only sessions the PROCESS started (in-memory allowlist, so a restart fails closed) or, inside a pane, the calling session's own children, unless `--allow-any-session`; never its own session. ⚠️ `read_output` returns text ONLY, no `structuredContent` (a client that prefers it showed the model metadata instead of the output: measured). Waits default 45 s (Codex's MCP tool timeout is 60 s). Registry manifest `config/mcp-registry/server.json` (`mcpName` in package.json, version kept by `scripts/sync-plugin.mjs`). Tests: `test/mcp-server.test.ts`. **Agent-created case marker** (`src/agent-case-marker.ts`): a case dir that `POST /api/quick-start` CREATES for an agent-driven spawn (signal: the preamble's `X-Codeman-Agent-Origin` header / `agentOrigin` field, else a resolved `parentSessionId`) gets `.codeman-agent-case.json`, published as `agentCreated` on `GET /api/cases`; `GET /api/cases/agent-created` is the cleanup listing (`inUse`, `modifiedAt`) behind Add Case → Manage. ⚠️ Only the branch that CREATES the directory may write it: never label a linked case, cloned repo or pre-existing path (it drives a recursive delete). ⚠️ Reading is total: anything but a well-formed v1 marker reads as not agent-created. ⚠️ Removal stays on `DELETE /api/cases/:name` (the ONE recursive-delete path), and the sweep excludes `inUse` cases. ⚠️ Changing the preamble's headers requires bumping `CODEMAN_PREAMBLE`. → [architecture-invariants#agent-created-case-marker](docs/architecture-invariants.md#agent-created-case-marker) diff --git a/docs/api-reference.md b/docs/api-reference.md index dd538bd3..ca04f1cf 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -608,8 +608,13 @@ The initial read selects recent complete records; `omittedBeforeBytes` explicitl labels its tail omission. Cursor reads consume only complete appended records, keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution -separately caps at 8192 directory entries, eight directory levels and 1 MiB of -metadata reads. Oversized/unfinished records report `blockedReason`; incremental +separately caps at 8192 directory entries, eight directory levels, 8 MiB of +negative-filter prefix reads and 1 MiB of candidate metadata reads (including +final identity verification). Unreachable workspace/home/metadata directories +are probed with a bounded past-cap probe before canonical reads and remain +unattributed. A full incremental page can be continued with its cursor; only a +first pending record too large for the budget reports an output-budget block. +Oversized/unfinished records report `blockedReason`; incremental reads do not silently skip them. Other modes, remote/container sessions, and unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, `cursor:null`; these snapshots can repeat evidence. @@ -622,7 +627,8 @@ conversation identity and do not use these observations as proof of the active thread, task completion or authorization. Opaque server-side cursors bind caller, session, pane generation, conversation, -opened-file identity, byte offset and bounded content anchors. At most 1024 live +opened-file identity, byte offset and bounded content anchors. Terminal resizes +do not change this inspection scope. At most 1024 live cursors are retained for 15 minutes; restart, eviction or expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign cursors or loss of the attributed source return `409 CONFLICT`. No transcript @@ -633,7 +639,10 @@ Guarded input initially requires a local characterized Codex composer, one unwrapped visible line, a recognized footer and a cursor at its exact end. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing -whitespace (the existing mux transport trims it). Its two-minute receipt stores +whitespace (the existing mux transport trims it). Before typing, it checks the +actual captured pane width and rendered composer indentation, refusing any +message that would reach the right edge or wrap. Every declared Codex effort +word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), never persisted prompt content. A receipt acknowledges typing; a delayed screen paint can still make submit refuse until exact verification becomes possible. @@ -644,7 +653,7 @@ input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, -confirmation hash, expiry, lifecycle/input revisions and exact composer/caret, +confirmation hash, expiry, lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7a743bb6..140d394d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1260,3 +1260,30 @@ Its CLI knowledge is a GENERATED block (`npm run generate:cli-catalog`, markers ⚠️ It must stay **bash 3.2** clean — macOS ships it and the documented install is `curl | bash` under `set -euo pipefail`, so `declare -A`, `mapfile`, namerefs, `${x,,}` and here-strings are all fatal there; CI runs `bash -n` plus a real `bash:3.2` container, since expanding an EMPTY array under `set -u` is a runtime abort `bash -n` cannot see. ⚠️ It executes ONLY commands from the embedded block (`CLI_INSTALL_CMD_TRUSTED`) — there is no network fetch of the catalogue at install time to worry about at all. + +## Agent inspection and guarded submission + +`agent-session-routes.ts` is registered by `WebServer` with the other route +modules. Ownership is checked before transcript/pane/receipt lookup. Inspection +uses strict native metadata attribution, bounded traversal, separate prefix and +candidate-metadata budgets, bounded path probes before canonical reads, and +complete UTF8 JSONL records. A full page advances its cursor without claiming a +blocked record. Opaque cursors bind caller/session/lifecycle/file identity; pane +geometry is excluded so changing devices does not invalidate transcript paging. + +Staging requires a fresh, known local Codex composer/caret capture. The footer +uses the shared `CODEX_REASONING_EFFORTS` vocabulary. Before any write, available +columns are calculated from actual captured geometry and the rendered prefix, +including indentation; reaching the last column or wrapping is refused. Stage +cancels older verifier retries and sends no Enter. Receipts contain hashes and +bounded ephemeral identities only, expire, and are consumed before awaited +submission, including failures. + +Submission additionally binds input and geometry revisions and rechecks the +exact composer/caret at the conditional Enter boundary. The route must retain +the Session snapshot's staging generation rather than replacing it with the +inspection generation. Managed input and auxiliary named keys participate in the +same pending/lease boundary. Complete focus/mouse reports and empty writes are +not edits. Never retry ambiguous Enter delivery or clear an unverified composer. +The ordinary SubmitVerifier uses the shared write boundary, so each re-press +updates `lastSubmitAt` just like any other Enter. diff --git a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md index 394f6be4..bb15a5e6 100644 --- a/plugins/codeman/skills/codeman/reference/inspection-and-staging.md +++ b/plugins/codeman/skills/codeman/reference/inspection-and-staging.md @@ -1,9 +1,8 @@ # Bounded inspection and guarded submission -Use these commands inside a Codeman session. They use its authenticated HTTP API; -no direct tmux access, second transport, or session-bridge installation is required. -They supplement `send`, `wait` and `read`. They do not establish full parity with -session-bridge or retire that skill. +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. ## Inspect recent evidence @@ -31,7 +30,8 @@ falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator thread may leave launch metadata unchanged. Check `identity.conversationId`; these reads do not provide authoritative current-thread tracking. -Cursors are opaque, scoped to the requesting identity, session, pane generation +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server restart. At most 1024 are retained; capacity eviction can invalidate an older cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor @@ -43,7 +43,8 @@ never split or silently skip one. Increase the byte budget for `record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, use `read --tail` for a labeled terminal view or start a fresh tail baseline. Inspect scans at most 256 KiB of record data plus bounded generation anchors. -Attribution separately caps directory entries and metadata bytes. Evidence and +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and activity are observations, not proof of task completion or authorization. ## Stage, inspect, then submit once @@ -58,7 +59,9 @@ codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --jso Stage types the file contents **without Enter**. The file must be regular, UTF8, one printable line, at most 8192 bytes, with no control characters or trailing whitespace/newline. It initially supports a local Codex composer with a recognized -footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, wrapped, remote/container and shell composers are refused. A customized footer or unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. @@ -70,7 +73,7 @@ requires the fully painted exact composer. If typing fails or is ambiguous, stag issues no receipt; inspect the pane before trying again. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, -exact composer and caret, then sends one conditional Enter at the session's input +geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures @@ -82,3 +85,34 @@ replay Enter, and there is no autonomous retry. A successful response means one Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. + +## Recover after a refused submit + +A consumed receipt cannot be reused. First observe whether the prompt was +accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 3f511649..dd68d64d 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. diff --git a/skills/codeman/reference/inspection-and-staging.md b/skills/codeman/reference/inspection-and-staging.md index 394f6be4..bb15a5e6 100644 --- a/skills/codeman/reference/inspection-and-staging.md +++ b/skills/codeman/reference/inspection-and-staging.md @@ -1,9 +1,8 @@ # Bounded inspection and guarded submission -Use these commands inside a Codeman session. They use its authenticated HTTP API; -no direct tmux access, second transport, or session-bridge installation is required. -They supplement `send`, `wait` and `read`. They do not establish full parity with -session-bridge or retire that skill. +Use these commands inside a Codeman session. They use its authenticated HTTP API. +They supplement `send`, `wait` and `read` over the same HTTP transport; no direct +tmux access is required. ## Inspect recent evidence @@ -31,7 +30,8 @@ falls back to a snapshot. An in-CLI `/resume` of an existing foreign-originator thread may leave launch metadata unchanged. Check `identity.conversationId`; these reads do not provide authoritative current-thread tracking. -Cursors are opaque, scoped to the requesting identity, session, pane generation +Terminal resizes preserve inspection cursors. Cursors are opaque, scoped to the +requesting identity, session, pane lifecycle generation and attributed transcript, expire after 15 minutes, and disappear on server restart. At most 1024 are retained; capacity eviction can invalidate an older cursor. A replaced/truncated/rewritten source, reassociated session, foreign cursor @@ -43,7 +43,8 @@ never split or silently skip one. Increase the byte budget for `record-exceeds-output-budget`; when the record exceeds the maximum or scan cap, use `read --tail` for a labeled terminal view or start a fresh tail baseline. Inspect scans at most 256 KiB of record data plus bounded generation anchors. -Attribution separately caps directory entries and metadata bytes. Evidence and +Attribution separately caps directory entries (8192), depth (8), negative-filter +prefix reads (8 MiB) and candidate metadata including final verification (1 MiB). Evidence and activity are observations, not proof of task completion or authorization. ## Stage, inspect, then submit once @@ -58,7 +59,9 @@ codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --jso Stage types the file contents **without Enter**. The file must be regular, UTF8, one printable line, at most 8192 bytes, with no control characters or trailing whitespace/newline. It initially supports a local Codex composer with a recognized -footer and an exact, unwrapped line/caret capture. Unknown, working, modal, +footer and an exact, unwrapped line/caret capture. Before typing, stage checks +the actual captured width and composer indentation; a message that would reach +the right edge or wrap is refused without writing anything. Unknown, working, modal, wrapped, remote/container and shell composers are refused. A customized footer or unsupported character-width rendering can also make verification unavailable. Self-targets are refused by the CLI. @@ -70,7 +73,7 @@ requires the fully painted exact composer. If typing fails or is ambiguous, stag issues no receipt; inspect the pane before trying again. Submit checks the caller, session, hash, expiry, pane lifecycle, input revision, -exact composer and caret, then sends one conditional Enter at the session's input +geometry, exact composer and caret, then sends one conditional Enter at the session's input boundary. Competing Codeman writes are refused during dispatch. Browser/API/key input and managed restarts invalidate receipts even when the visible text or pid is restored. Direct tmux edits are outside that input serialization; captures @@ -82,3 +85,34 @@ replay Enter, and there is no autonomous retry. A successful response means one Enter was delivered; use inspection or a suitable wait signal to observe native acceptance. A `409` or broken connection means inspect first. Do not automatically stage/submit again or send an unguarded Enter to recover. + +## Recover after a refused submit + +A consumed receipt cannot be reused. First observe whether the prompt was +accepted: use transcript evidence/wait signals and a fresh terminal capture +(`GET /api/v1/sessions/:id/terminal?tail=8192`). If the target is working, has +accepted the prompt, shows a dialog, or delivery is ambiguous, keep observing; +do not clear or resend anything. A transcript prompt row alone is not proof +that text remains unsent in the current composer. + +Only recover an unambiguously unsent draft in an input-ready local Codex +composer: verify the complete draft, recognized footer and caret at its end in +the current frame, and ensure no other writer can edit this target during +recovery. If the frame is wrapped, incomplete or unclear, ask the user to clear +it in the terminal instead of guessing keys. Never use `send` to overwrite a +draft: that appends and submits both prompts together. + +After those checks, the known Codex line-clear chord may be sent once through +the existing raw input route, with **no Enter**. This conditional example uses +the authenticated `CURL`/`API` values loaded by §0 and the exact target id: + +```bash +"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ + -H 'Content-Type: application/json' -d '{"input":"\u0015","useMux":false}' +``` + +Do not retry a failed/ambiguous clear blindly. Read a fresh terminal frame and +verify the composer is empty and input-ready. Only then create a **fresh** stage +receipt for the intended prompt and submit that receipt once. If the draft did +not clear, the target began working, or verification remains uncertain, stop and +ask the user; do not send an unguarded Enter or replay the old prompt. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 3f511649..dd68d64d 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -550,8 +550,10 @@ this (`src/web/routes/approval-routes.ts:43`). ESC is then claude's own interrup that half is the CLI's behavior, not something this API guarantees. - **This is not the composer-clearing tool.** Esc (and Ctrl+U) do **not** clear a - typed-but-unsubmitted prompt, verified live. The only recovery there is to submit it - with `{"input":"\r"}` and let the worker read the junk line. + typed-but-unsubmitted Claude prompt, verified live. Do not submit an unverified + draft merely to clear it. For a refused guarded local Codex submit, use the + verified-unsent recovery in [inspection and staging](inspection-and-staging.md#recover-after-a-refused-submit); + when the composer is unclear, ask the user to clear it in the terminal. - The interrupted turn already burned its tokens. Interrupting early saves the rest. - `POST /api/sessions/:id/send-key` is a different endpoint and cannot do this: its allowlist is S-Enter / C-Enter only. diff --git a/src/agent-codex-identity.ts b/src/agent-codex-identity.ts index 81a26162..97cfe50c 100644 --- a/src/agent-codex-identity.ts +++ b/src/agent-codex-identity.ts @@ -2,13 +2,14 @@ * @fileoverview Bounded Codex rollout attribution for agent evidence. Never uses * newest-mtime, prompt timestamps or cwd alone as proof of session ownership. */ -import { realpathSync } from 'node:fs'; -import { open, opendir } from 'node:fs/promises'; +import { open, opendir, realpath } from 'node:fs/promises'; +import { probePathKind } from './utils/bounded-path-probe.js'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; const UUID_AT_END = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const HEAD_BYTES = 131072; +const PREFIX_SCAN_BYTES = 8 * 1024 * 1024; export interface CodexAttributionOptions { codexHome: string; sessionId: string; @@ -18,15 +19,18 @@ export interface CodexAttributionOptions { export interface CodexAttributionBudget { maxEntries: number; maxBytes: number; + /** Separate bound for cheap negative prefixes; full candidate metadata has its own budget. */ + maxPrefixBytes?: number; } interface Metadata { id?: string; cwd?: string; originator?: string; } -function canonical(path: string): string | undefined { +async function canonical(path: string): Promise { + if ((await probePathKind(path, { pastCap: true })) !== 'directory') return undefined; try { - return realpathSync.native(path); + return await realpath(path); } catch { return undefined; } @@ -38,7 +42,7 @@ export function resolveEffectiveCodexHome( env: Readonly> = process.env ): string { const path = resolve(workingDir, sessionHome || env.CODEX_HOME || join(env.HOME || tmpdir(), '.codex')); - return canonical(path) ?? path; + return path; } /** Complete metadata only: a prefix or a partially flushed JSONL row proves nothing. */ function strictMetadata(bytes: Buffer): Metadata | undefined { @@ -72,11 +76,18 @@ export async function findExactCodexConversation( options: CodexAttributionOptions, budget: CodexAttributionBudget = { maxEntries: 8192, maxBytes: 1048576 } ): Promise<{ id: string; path: string } | undefined> { - const directories = [{ path: join(options.codexHome, 'sessions'), depth: 0 }]; + const workspace = await canonical(options.workingDir); + const home = await canonical(options.codexHome); + if (!workspace || !home) return undefined; + const sessions = join(home, 'sessions'); + if ((await probePathKind(sessions, { pastCap: true })) !== 'directory') return undefined; + const directories = [{ path: sessions, depth: 0 }]; const candidates: Array<{ id: string; path: string; meta: Metadata }> = []; const originator = `codeman_${options.sessionId}`; const explicit = options.resumeSessionId; let entries = 0; + let prefixes = 0; + const prefixLimit = Math.min(budget.maxPrefixBytes ?? PREFIX_SCAN_BYTES, PREFIX_SCAN_BYTES); let scanned = HEAD_BYTES; // Reserve an independent final verification. while (directories.length) { const dir = directories.pop()!; @@ -96,8 +107,8 @@ export async function findExactCodexConversation( if (!info.isFile()) return undefined; const id = entry.name.match(UUID_AT_END)?.[1]; const prefixBytes = Math.min(info.size, 4096); - if (scanned + prefixBytes > budget.maxBytes) return undefined; - scanned += prefixBytes; + if (prefixes + prefixBytes > prefixLimit) return undefined; + prefixes += prefixBytes; const prefix = Buffer.alloc(prefixBytes); const first = await file.read(prefix, 0, prefixBytes, 0); // Cheap negative filter only; a match still needs full parsed metadata. @@ -122,12 +133,7 @@ export async function findExactCodexConversation( } if (candidates.length !== 1) return undefined; const candidate = candidates[0]; - if ( - !candidate.meta.cwd || - canonical(candidate.meta.cwd) !== canonical(options.workingDir) || - !canonical(options.workingDir) - ) - return undefined; + if (!candidate.meta.cwd || (await canonical(candidate.meta.cwd)) !== workspace) return undefined; const handle = await open(candidate.path, 'r').catch(() => undefined); if (!handle) return undefined; try { diff --git a/src/agent-inspection.ts b/src/agent-inspection.ts index 1fc15b4e..c48c90fc 100644 --- a/src/agent-inspection.ts +++ b/src/agent-inspection.ts @@ -134,7 +134,7 @@ export class InspectionReader { break; } if (end + 1 > maxBytes) { - blockedReason = 'record-exceeds-output-budget'; + if (consumed === 0) blockedReason = 'record-exceeds-output-budget'; break; } consumed = end + 1; diff --git a/src/agent-staging.ts b/src/agent-staging.ts index ff8cc8c9..2dbd2382 100644 --- a/src/agent-staging.ts +++ b/src/agent-staging.ts @@ -6,12 +6,18 @@ import { createHash, randomUUID } from 'node:crypto'; import { stripAnsi } from './utils/regex-patterns.js'; import { visibleWidth } from './tui/tui-ansi.js'; +import { CODEX_REASONING_EFFORTS } from './types/session.js'; +const FOOTER_LINE = new RegExp( + String.raw`^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · )` +); export const STAGE_MAX_BYTES = 8192; export const promptSha256 = (text: string): string => createHash('sha256').update(text).digest('hex'); export interface GuardedInputSnapshot { generation: string; revision: number; composerSha: string; + /** Actual captured pane width minus the rendered composer prefix (including indentation). */ + availableColumns: number; } export interface GuardedInputTarget { id: string; @@ -40,17 +46,7 @@ export function readGuardedCodexComposer(screen: string): string | null { const last = rows.at(-1); if (!last) return null; const below = lines.slice(last.index + 1).filter((line) => line.trim()); - if ( - below.length === 0 || - below.length > 3 || - below.some( - (line) => - !/^\s+(?:\d+% context left|\? for shortcuts|← for agents|\S+ (?:default|low|medium|high|xhigh|max) · )/.test( - line - ) - ) - ) - return null; + if (below.length === 0 || below.length > 3 || below.some((line) => !FOOTER_LINE.test(line))) return null; // Preserve exact printable content; only SGR-dim placeholder/suggestion text is excluded. const raw = screen.split('\n')[last.index]; let dim = false; @@ -74,6 +70,10 @@ export function readGuardedCodexComposer(screen: string): string | null { /** Require the capture's caret at the exact end of the unwrapped composer. */ export function readGuardedCodexSnapshot(snapshot: string): string | null { + return readGuardedCodexSnapshotDetails(snapshot)?.composer ?? null; +} +/** Exact input-row prefix width, shared by proof and preflight wrapping checks. */ +export function readGuardedCodexSnapshotDetails(snapshot: string): { composer: string; prefixWidth: number } | null { // formatPaneSnapshot emits one absolute row address per line, then the caret. // Plain captures lack caret evidence and cannot prove invisible trailing edits. // eslint-disable-next-line no-control-regex @@ -98,7 +98,7 @@ export function readGuardedCodexSnapshot(snapshot: string): string | null { const prefix = stripAnsi(rows[composerRow]).match(/^(\s*›[ \u00a0])/); if (!prefix || Number(caret[1]) !== composerRow + 1 || Number(caret[2]) !== visibleWidth(prefix[1] + composer) + 1) return null; - return composer; + return { composer, prefixWidth: visibleWidth(prefix[1]) }; } export class GuardedStageStore { @@ -129,6 +129,8 @@ export class GuardedStageStore { const before = target.snapshot(); if (!before || before.composerSha !== promptSha256('')) throw new GuardedInputConflict('An empty, verifiable composer is required'); + if (!Number.isFinite(before.availableColumns) || visibleWidth(message) >= before.availableColumns) + throw new GuardedInputConflict('Message would reach the pane edge or wrap; shorten it or widen the pane'); for (const [id, receipt] of this.receipts) if (receipt.expiresAt <= this.now() || receipt.sessionId === target.id) this.receipts.delete(id); // Refuse capacity before typing. Do not evict a caller's still-live receipt. @@ -139,7 +141,12 @@ export class GuardedStageStore { if (!(await target.write(message))) throw new GuardedInputConflict('Staging delivery failed; inspect the pane before trying again'); const after = target.snapshot(); - if (!after || after.generation !== before.generation || after.revision !== before.revision + 1) + if ( + !after || + after.generation !== before.generation || + after.revision !== before.revision + 1 || + after.availableColumns !== before.availableColumns + ) throw new GuardedInputConflict('Input or pane changed while staging; no receipt was issued'); const stageId = randomUUID(); const expiresAt = this.now() + this.ttlMs; @@ -166,6 +173,7 @@ export class GuardedStageStore { !current || current.generation !== receipt.generation || current.revision !== receipt.revision || + current.availableColumns !== receipt.availableColumns || current.composerSha !== receipt.sha256 ) throw new GuardedInputConflict('Staged composer, input, or pane identity changed; nothing submitted'); diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 94c36283..57fa360e 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -314,9 +314,9 @@ export interface TerminalMultiplexer extends EventEmitter { // ========== Input ========== /** - * Send input to a session via tmux send-keys. + * Send input to a session via tmux send-keys. The optional internal Enter-only + * guard runs synchronously immediately before transport dispatch. */ - /** Internal conditional Enter guard, checked synchronously before transport dispatch. */ sendInput(sessionId: string, input: string, options?: { beforeDispatch?: () => boolean }): Promise; // ========== Metadata ========== diff --git a/src/session.ts b/src/session.ts index 6f673222..8d3a5791 100644 --- a/src/session.ts +++ b/src/session.ts @@ -129,7 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; -import { promptSha256, readGuardedCodexSnapshot, type GuardedInputSnapshot } from './agent-staging.js'; +import { promptSha256, readGuardedCodexSnapshotDetails, type GuardedInputSnapshot } from './agent-staging.js'; import { isKeyboardTerminalInput } from './terminal-input-classifier.js'; import { stripAnsi } from './utils/regex-patterns.js'; import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; @@ -4727,26 +4727,9 @@ export class Session extends EventEmitter { // Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps) - /** - * Sends input directly to the PTY process. - * - * For interactive sessions, this is how you send user input to Claude. - * Remember to include `\r` (carriage return) to simulate pressing Enter. - * - * @example - * ```typescript - * session.write('hello world'); // Text only, no Enter - * session.write('\r'); // Enter key - * session.write('ls -la\r'); // Command with Enter - * ``` - * - * @param data - The input data to send (text, escape sequences, etc.) - * @returns true if the data reached a PTY. A session whose PTY is gone still - * discards the data, but it used to do so with no signal at all — which is how - * input could disappear while the caller believed it had been delivered. - */ private readonly _inspectionInstance = uuidv4(); private _guardedPaneRevision = 0; + private _guardedGeometryRevision = 0; private _guardedInputRevision = 0; private _guardedPendingWrites = 0; private _guardedSubmitInFlight = false; @@ -4754,6 +4737,10 @@ export class Session extends EventEmitter { get inspectionGeneration(): string { return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`; } + /** Staging additionally binds geometry; transcript cursors survive a terminal resize. */ + get guardedInputGeneration(): string { + return `${this.inspectionGeneration}:${this._guardedGeometryRevision}`; + } get guardedInputPendingWrites(): number { return this._guardedPendingWrites; } @@ -4781,7 +4768,8 @@ export class Session extends EventEmitter { !this._muxSession ) return null; - const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName); + const capture: import('./mux-interface.js').PaneCaptureOptions = {}; + const screen = this._mux?.capturePaneBuffer?.(this._muxSession.muxName, undefined, capture); if (!screen) return null; // tmux's replay has CUP addresses rather than line breaks. Read the SAME // fresh frame for dialog/work chrome and exact composer/caret proof. @@ -4790,13 +4778,16 @@ export class Session extends EventEmitter { const working = this._workingLinePattern(); working.lastIndex = 0; if (this._paneShowsBlocked(plain) || working.test(plain) || this._paneAwaitsWorkers(plain)) return null; - const composer = readGuardedCodexSnapshot(screen); + const composer = readGuardedCodexSnapshotDetails(screen); return composer === null ? null : { - generation: this.inspectionGeneration, + generation: this.guardedInputGeneration, revision: this._guardedInputRevision, - composerSha: promptSha256(composer), + composerSha: promptSha256(composer.composer), + availableColumns: Number.isFinite(capture.capturedGeometry?.cols) + ? capture.capturedGeometry!.cols - composer.prefixWidth + : 0, }; } /** Stage through ordinary delivery, after cancelling any older Enter retry timer. */ @@ -4815,7 +4806,8 @@ export class Session extends EventEmitter { !!current && current.generation === expected.generation && current.revision === expected.revision && - current.composerSha === expected.composerSha; + current.composerSha === expected.composerSha && + current.availableColumns === expected.availableColumns; if (!matches(this.guardedInputSnapshot()) || !this._mux || !this._muxSession) return false; this._guardedSubmitInFlight = true; let dispatched = false; @@ -4845,6 +4837,24 @@ export class Session extends EventEmitter { this._guardedPendingWrites--; } } + /** + * Sends input directly to the PTY process. + * + * For interactive sessions, this is how you send user input to Claude. + * Remember to include `\r` (carriage return) to simulate pressing Enter. + * + * @example + * ```typescript + * session.write('hello world'); // Text only, no Enter + * session.write('\r'); // Enter key + * session.write('ls -la\r'); // Command with Enter + * ``` + * + * @param data - The input data to send (text, escape sequences, etc.) + * @returns true if the data reached a PTY. A session whose PTY is gone still + * discards the data, but it used to do so with no signal at all — which is how + * input could disappear while the caller believed it had been delivered. + */ write(data: string, options: SessionWriteOptions = {}): boolean { this._observeGuardedKeyboardInput(data); if (this._guardedSubmitInFlight) return false; @@ -5219,7 +5229,7 @@ export class Session extends EventEmitter { } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; if (this.ptyProcess && (dimsChanged || options.force)) { - this._guardedPaneRevision++; + this._guardedGeometryRevision++; this._ptyCols = cols; this._ptyRows = rows; if (!IS_TEST_MODE && this._mux && this._muxSession) { diff --git a/src/web/routes/agent-session-routes.ts b/src/web/routes/agent-session-routes.ts index a3892b34..662cf41f 100644 --- a/src/web/routes/agent-session-routes.ts +++ b/src/web/routes/agent-session-routes.ts @@ -27,7 +27,7 @@ export function registerAgentSessionRoutes(app: FastifyInstance, ctx: SessionPor snapshot: () => { if (ctx.sessions.get(session.id) !== session) return null; const snapshot = session.guardedInputSnapshot?.(); - return snapshot ? { ...snapshot, generation: generation(session) } : null; + return snapshot ?? null; }, write: (text) => session.stageGuardedInput(text), submit: (expected) => session.submitGuardedInput(expected), diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index ec5e0e80..ed887306 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -47,3 +47,5 @@ export { type CustomModelSwapDisplacement, } from './custom-model-routes.js'; export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js'; + +export { registerAgentSessionRoutes } from './agent-session-routes.js'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 507ef7ab..fbceb684 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,7 +5,6 @@ */ import { claudeProjectDirName } from '../../case-rename.js'; -import { registerAgentSessionRoutes } from './agent-session-routes.js'; import { FastifyInstance, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; @@ -1073,8 +1072,6 @@ export function registerSessionRoutes( // ========== Logout ========== - registerAgentSessionRoutes(app, ctx); - app.post('/api/logout', async (req, reply) => { // Invalidate server-side session token (not just the browser cookie) const sessionToken = req.cookies[AUTH_COOKIE_NAME]; diff --git a/src/web/server.ts b/src/web/server.ts index 2b3e582c..3e2d07b8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -213,6 +213,7 @@ import { registerCaseRoutes, registerWorktreeRoutes, registerSessionRoutes, + registerAgentSessionRoutes, registerRespawnRoutes, registerRalphRoutes, registerPlanRoutes, @@ -1249,6 +1250,7 @@ export class WebServer extends EventEmitter { // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); + registerAgentSessionRoutes(this.app, ctx); registerRespawnRoutes(this.app, ctx); registerRalphRoutes(this.app, ctx); registerPlanRoutes(this.app, ctx); diff --git a/test/agent-codex-identity.test.ts b/test/agent-codex-identity.test.ts index b568b3d5..d3bc9cad 100644 --- a/test/agent-codex-identity.test.ts +++ b/test/agent-codex-identity.test.ts @@ -117,3 +117,40 @@ it.each([ } } ); + +it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => { + const f = await fixture(); + const path = await f.put(A); + await Promise.all( + Array.from({ length: 320 }, (_, n) => { + const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`; + return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) }); + }) + ); + expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path }); + expect( + await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 }) + ).toBeUndefined(); +}); + +it.each(['workspace', 'metadata'] as const)( + 'probes %s bounded past the cap before canonical filesystem reads', + async (where) => { + const { vi } = await import('vitest'); + const probe = await import('../src/utils/bounded-path-probe.js'); + const f = await fixture(); + const alias = join(f.root, 'workspace-alias'); + await symlink(f.root, alias); + await f.put(A, { cwd: where === 'metadata' ? alias : f.root }); + const blocked = where === 'workspace' ? f.root : alias; + const spy = vi + .spyOn(probe, 'probePathKind') + .mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory')); + try { + expect(await findExactCodexConversation(f.options)).toBeUndefined(); + expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true }); + } finally { + spy.mockRestore(); + } + } +); diff --git a/test/agent-inspection.test.ts b/test/agent-inspection.test.ts index 2a1f4270..989f005b 100644 --- a/test/agent-inspection.test.ts +++ b/test/agent-inspection.test.ts @@ -159,3 +159,22 @@ it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete await appendFile(file, row.subarray(split)); expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8')); }); + +it('treats a full incremental page as pageable rather than a blocked oversized record', async () => { + await writeFile(file, '{}\n'); + const initial = await reader.read(file, scope, 100); + await appendFile(file, '{}\n{}\n'); + const page = await reader.read(file, scope, 3, initial.cursor); + expect(page.evidence).toBe('{}\n'); + expect(page.truncated).toBe(true); + expect(page.blockedReason).toBeUndefined(); + const next = await reader.read(file, scope, 3, page.cursor); + expect(next.evidence).toBe('{}\n'); +}); + +it('still labels the first selected oversized tail record after omitted older bytes', async () => { + await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n'); + const page = await reader.read(file, scope, 10); + expect(page.evidence).toBe(''); + expect(page.blockedReason).toBe('record-exceeds-output-budget'); +}); diff --git a/test/agent-staging-session.test.ts b/test/agent-staging-session.test.ts index f0bb1328..8d97cc23 100644 --- a/test/agent-staging-session.test.ts +++ b/test/agent-staging-session.test.ts @@ -7,13 +7,19 @@ it('fences writes and reused-pid pane lifecycles without persisting prompt conte let screen = '› \n GPT-6-Luna default · ~/case\n'; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(screen.trimEnd().split('\n'), { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(screen.trimEnd().split('\n'), { cols: 100, rows: 20, cursorX: screen.startsWith('› hello') ? 7 : 2, cursorY: 0, - }), + }); + }, sendInput: async () => true, }; const session = new Session({ @@ -49,8 +55,19 @@ it('checks conditional Enter at the dispatch boundary and rejects concurrent bro const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { await new Promise((resolve) => { dispatch = resolve; @@ -104,13 +121,19 @@ it('cancels an older automatic submit verifier before staging an identical promp const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 2 + composer.length, cursorY: 0, - }), + }); + }, capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`, sendInput: async (_id: string, text: string) => { writes.push(text); @@ -141,8 +164,19 @@ it('routes automatic verifier Enter through the shared guarded input boundary', const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case', sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { if (options?.beforeDispatch) { @@ -180,8 +214,19 @@ it('refuses legacy one-shot prompt entrypoints during a guarded submission befor const writes: string[] = []; const mux = { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0 }), + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + cols: 100, + rows: 20, + cursorX: 7, + cursorY: 0, + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { await new Promise((resolve) => { release = resolve; @@ -259,7 +304,14 @@ it('uses fresh normalized pane rows for working/dialog denial before exact compo mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: capture, + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return capture(); + }, capturePaneText: () => { throw Error('must use same frame'); }, @@ -287,13 +339,19 @@ it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 7, cursorY: 0, - }), + }); + }, }, muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, } as unknown as ConstructorParameters[0]); @@ -319,11 +377,90 @@ it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop session.owner = 'other'; session.owner = undefined; expect(session.inspectionGeneration).not.toBe(generation); - const priorResize = session.inspectionGeneration; + const priorResize = session.guardedInputGeneration; session.resize(80, 24); - expect(session.inspectionGeneration).not.toBe(priorResize); + expect(session.guardedInputGeneration).not.toBe(priorResize); } finally { access.ptyProcess = null; await session.stop(false); } }); + +it('refuses a message that would wrap using the actual captured indent and width before any write', async () => { + const writes: string[] = []; + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { + isAvailable: () => true, + capturePaneBuffer: ( + _name: string, + _pane: string, + opts: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 30, rows: 20 }; + return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], { + cols: 30, + rows: 20, + cursorX: 6, + cursorY: 0, + }); + }, + sendInput: async (_id: string, text: string) => { + writes.push(text); + return true; + }, + }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + const { GuardedStageStore } = await import('../src/agent-staging.js'); + try { + const store = new GuardedStageStore(); + const target = { + id: session.id, + snapshot: () => session.guardedInputSnapshot(), + write: (text: string) => session.stageGuardedInput(text), + submit: (snap: Parameters[0]) => session.submitGuardedInput(snap), + }; + await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(); + expect(writes).toEqual([]); + } finally { + await session.stop(false); + } +}); + +it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => { + const session = new Session({ workingDir: '/tmp', mode: 'codex' }); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + try { + const inspection = session.inspectionGeneration; + const staging = session.guardedInputGeneration; + session.resize(80, 24); + expect(session.inspectionGeneration).toBe(inspection); + expect(session.guardedInputGeneration).not.toBe(staging); + } finally { + access.ptyProcess = null; + await session.stop(false); + } +}); + +it('records a verifier re-press as the latest submit time', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10000); + const session = new Session({ + workingDir: '/tmp', + mode: 'codex', + mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true }, + muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 }, + } as unknown as ConstructorParameters[0]); + try { + await session.writeViaMux('hello\r'); + const initial = session.lastSubmitAt; + await vi.advanceTimersByTimeAsync(2000); + expect(session.lastSubmitAt).toBe(initial + 2000); + } finally { + await session.stop(false); + vi.useRealTimers(); + } +}); diff --git a/test/agent-staging.test.ts b/test/agent-staging.test.ts index 1cd920a6..6a9c1ad3 100644 --- a/test/agent-staging.test.ts +++ b/test/agent-staging.test.ts @@ -1,6 +1,7 @@ /** @fileoverview Guarded staging one-shot and content-verification safety. */ import { beforeEach, expect, it } from 'vitest'; import { createHash } from 'node:crypto'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; import { GuardedStageStore, type GuardedInputTarget } from '../src/agent-staging.js'; const sha = (s: string) => createHash('sha256').update(s).digest('hex'); let store: GuardedStageStore; @@ -21,7 +22,8 @@ beforeEach(() => { store = new GuardedStageStore(() => time, 2, 1000); target = { id: 's', - snapshot: () => (composer === null ? null : { generation, revision, composerSha: sha(composer) }), + snapshot: () => + composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 }, submit: async () => target.write('\r'), write: async (text) => { writes.push(text); @@ -151,3 +153,18 @@ it('reserves receipt capacity before concurrent stages type', async () => { release!(); await first; }); + +it.each([...CODEX_REASONING_EFFORTS, 'default'])( + 'accepts the shared Codex effort word %s in the footer', + async (word) => { + const { readGuardedCodexComposer } = await import('../src/agent-staging.js'); + expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello'); + } +); + +it('refuses messages that reach the available pane columns before typing', async () => { + target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 }); + for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow(); + expect(writes).toEqual([]); + await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false }); +}); diff --git a/test/routes/agent-session-routes.test.ts b/test/routes/agent-session-routes.test.ts index 02cec22b..27fbd415 100644 --- a/test/routes/agent-session-routes.test.ts +++ b/test/routes/agent-session-routes.test.ts @@ -1,6 +1,6 @@ /** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */ import { afterEach, describe, expect, it } from 'vitest'; -import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { registerAgentSessionRoutes } from '../../src/web/routes/agent-session-routes.js'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; let harness: RouteTestHarness; @@ -9,7 +9,7 @@ afterEach(async () => { }); describe('agent session routes', () => { it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` }); expect(result.statusCode).toBe(200); @@ -19,7 +19,7 @@ describe('agent session routes', () => { expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20); }); it('refuses guarded staging for an uncharacterized composer', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; const result = await harness.app.inject({ method: 'POST', @@ -33,7 +33,9 @@ describe('agent session routes', () => { it('checks ownership before inspection, stage and submit', async () => { process.env.CODEMAN_MULTIUSER = '1'; try { - harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'user' } }); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'user' }, + }); const session = [...harness.ctx.sessions.values()][0]; session.owner = 'bob'; for (const request of [ @@ -52,7 +54,7 @@ it('checks ownership before inspection, stage and submit', async () => { } }); it('validates output budgets and refuses to resume a pane fallback', async () => { - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); const session = [...harness.ctx.sessions.values()][0]; session.terminalBuffer = '😀é'.repeat(20); for (const value of ['0', '-1', '65537', '1.5', 'NaN']) @@ -74,7 +76,7 @@ it('validates output budgets and refuses to resume a pane fallback', async () => it('uses real session writes to stage without Enter and refuses intervening API input', async () => { const { Session } = await import('../../src/session.js'); const { formatPaneSnapshot } = await import('../../src/tmux-manager.js'); - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); let composer = ''; const writes: string[] = []; const session = new Session({ @@ -83,13 +85,19 @@ it('uses real session writes to stage without Enter and refuses intervening API mode: 'codex', mux: { isAvailable: () => true, - capturePaneBuffer: () => - formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { + capturePaneBuffer: ( + _name: string, + _pane?: string, + opts?: { capturedGeometry?: { cols: number; rows: number } } + ) => { + if (opts) opts.capturedGeometry = { cols: 100, rows: 20 }; + return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: 2 + composer.length, cursorY: 0, - }), + }); + }, sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => { if (options?.beforeDispatch && !options.beforeDispatch()) return false; writes.push(text); @@ -157,7 +165,9 @@ it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its const row = JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'; await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n'); - harness = await createRouteTestHarness(registerSessionRoutes, { authUser: { username: 'alice', role: 'admin' } }); + harness = await createRouteTestHarness(registerAgentSessionRoutes, { + authUser: { username: 'alice', role: 'admin' }, + }); (harness.ctx.sessions as unknown as Map).set(session.id, session); try { const first = await harness.app.inject({ @@ -172,6 +182,13 @@ it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its expect(data.cursor).not.toContain(home); const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` }); expect(next.json().data.evidence).toBe(''); + const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null }; + access.ptyProcess = { resize: () => {} }; + session.resize(80, 24); + access.ptyProcess = null; + expect( + (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode + ).toBe(200); session.owner = 'changed-owner'; expect( (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode @@ -202,7 +219,7 @@ it('refuses a stale launch-resume cursor once a new managed rollout conflicts wi const row = (id: string, originator: string) => JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n'; await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch')); - harness = await createRouteTestHarness(registerSessionRoutes); + harness = await createRouteTestHarness(registerAgentSessionRoutes); (harness.ctx.sessions as unknown as Map).set(session.id, session); try { const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data; diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index c131c4e5..614cb904 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -326,3 +326,13 @@ describe('sendInput line breaks', () => { expect(answer.ran[1]).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); }); }); + +it('terminates literal operands in both sendInputToPane branches', async () => { + const { readFile } = await import('node:fs/promises'); + const src = await readFile(new URL('../src/tmux-manager.ts', import.meta.url), 'utf8'); + const start = src.indexOf(' sendInputToPane('); + const body = src.slice(start, src.indexOf('\n }\n', start)); + const literal = body.match(/send-keys[^`\n]*-l[^`\n]*/g) ?? []; + expect(literal).toHaveLength(2); + for (const command of literal) expect(command).toContain(' -l -- '); +}); diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts index e81612b9..6bce845a 100644 --- a/test/webserver-bound-port.test.ts +++ b/test/webserver-bound-port.test.ts @@ -40,6 +40,34 @@ describe('WebServer on an ephemeral port', () => { expect(ctx.port).toBe(socketPort()); }); + it('registers bounded agent routes with the server route modules', async () => { + const { Session } = await import('../src/session.js'); + const session = new Session({ id: 'agent-route-wiring', workingDir: process.env.HOME!, mode: 'shell' }); + const sessions = (server as unknown as { sessions: Map }).sessions; + sessions.set(session.id, session); + try { + const prefix = `http://127.0.0.1:${server.boundPort}/api/v1/sessions/${session.id}`; + const inspected = await fetch(prefix + '/inspect'); + expect(inspected.status).toBe(200); + expect((await inspected.json()).data.source).toBe('pane-snapshot'); + for (const [verb, body] of [ + ['stage', { message: 'hello' }], + ['submit', { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) }], + ] as const) { + const response = await fetch(prefix + '/' + verb, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(409); + expect((await response.json()).errorCode).toBe('CONFLICT'); + } + } finally { + sessions.delete(session.id); + await session.stop(false); + } + }); + it('exports the real port to the panes as CODEMAN_API_URL', () => { expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`); });