Merge pull request #694: feat(agent): COD-529 add bounded inspection and guarded submission

Conflicts resolved by keeping both sides:
- CLAUDE.md (Agent CLI): #694's opening (an HTTP client, the experimental
  agent-session-routes behind agentCoordinationEnabled) with #708's clause
  on the directory spawn and its two refusals.
- docs/api-reference.md (codeman agent): #708's spawn paragraph and --json
  shape, with #694's inspect/stage/submit in the verb list and the guarded
  endpoints named before "adds no route for the other verbs".
- src/cli-agent.ts (@fileoverview): #708's quick-start / directory spawn
  routes and #694's experimental routes in one endpoint list; the two
  node:fs/promises imports (#708's realpath+stat, #694's open+stat) merged
  into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 02:14:44 +01:00
commit 5908cc51fd
50 files changed
+5408 -116

No files matched your search

+162 -1
View File
@@ -426,6 +426,13 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
turn that never started. Verified live; this is the most common silent failure on
this endpoint.
Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT`
with nothing written while an experimental guarded submit holds the session's
input lease, or input held behind it is still being written (a few milliseconds;
see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)).
Retry it. Untagged input is held instead and written in arrival order once the
lease is released.
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
a separate key, and the server re-presses Enter while the prompt is still visibly
@@ -589,9 +596,163 @@ request returns. The capture runs synchronous tmux calls on the server; the
| `lines=<n>` | With `full=1` only: read at most `<n>` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. |
| `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. |
## Experimental bounded agent inspection and guarded input
**Experimental:** `codeman agent inspect`, `stage` and `submit`, and their
`/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases),
are labeled experimental, so the versioning policy's rule for experimental
surfaces applies (`docs/versioning-policy.md`, "Experimental agent
coordination"): they are not part of the stable contract. Their commands, flags, paths,
payloads and behavior may change or be removed in a minor or patch release.
Pin a compatible Codeman version when automating them. Authentication and
session ownership checks still apply.
**Opt-in:** all three routes answer `403 FORBIDDEN` while the synced
`agentCoordinationEnabled` setting is off (the default; App Settings,
Cross-session features, **Experimental agent coordination**), with a message
naming it. The check runs before session lookup and body validation, and reads
the saved setting on every request, so turning it on needs no restart.
`codeman agent inspect|stage|submit` print that refusal verbatim (exit 1).
These authenticated routes use the standard envelope and session ownership checks.
`/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before
any transcript, pane or receipt lookup.
| Endpoint | Parameters/body | Result |
| --- | --- | --- |
| `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` |
| `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter |
| `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter |
Inspection initially supports native local Codex JSONL with strict conversation
attribution, using the session's effective `CODEX_HOME`, explicit resume identity
or unique managed originator. It never selects an arbitrary newest cwd match.
The initial read selects recent complete records; `omittedBeforeBytes` explicitly
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, 8 MiB of
negative-filter prefix reads and 1 MiB of candidate metadata reads (including
final identity verification). The negative filter reads 1 KiB of each rollout
first and 4 KiB only when that window holds no complete `originator` field. Once
a rollout is attributed, later calls re-verify only that file (same file identity,
same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not
older than its own, for a second candidate such as a codex `/new`. A file that no
longer verifies falls back to one full walk; a verified file whose incremental scan
finds a second candidate or runs out of budget answers unattributed without one,
since the full walk reads a superset of that scan. So the caps bound the first call,
not every call.
An unexpected read error is `422 OPERATION_FAILED` (logged with the session id
and error code only), not a retryable `409`. 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.
Attribution identifies a file, not an authoritative current-thread signal. A
configured launch resume and a new owned rollout conflict and force fallback.
An in-CLI `/resume` of an existing foreign-originator thread may leave no new
owned rollout; launch metadata cannot detect that switch. Check the returned
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. Terminal resizes
do not change this inspection scope. Each owner and session keeps its newest 8
cursors (a retry of the last call still works; older ones are retired), at most
1024 live cursors are retained in all, each 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
filesystem path is returned as cursor/identity metadata; evidence retains the
owner's native record content, which can itself contain paths.
Guarded input initially requires a local characterized Codex composer, one
unwrapped visible line, a recognized footer and a cursor at its exact end. The
composer glyph and footer are the CLI registry's
`capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that
declares no `composerFooterLine` is refused.
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). 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.
Stage refuses, before typing anything, a message that would change what Enter
does rather than what it sends, from the registry's
`composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message
starting with `/` (a slash command; codex runs it, or with its command list open
runs the highlighted one) or `!` (a shell command), checked after any leading
whitespace, and a message whose last word starts with `@` (file and mention
search) or `$` (skills; every `$` word, since whether codex opens that list
depends on its skill catalog). Those popups follow the caret, which stage leaves
at the end of the message, so a `@` or `$` word earlier in the message is
accepted. Send slash and shell commands through `POST .../input`
(`codeman agent send`). Independently, a frame with a suggestion popup in the
block directly above the composer (the registry's `composerPopupLine`) is refused
at stage and submit, since codex draws every popup there and leaves the footer
below the composer unchanged.
A receipt is issued only for a capture that shows exactly the message with the
caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive,
so after typing stage re-reads the pane every 50 ms for up to one second and
takes the first frame with the same pane generation, the input revision its own
write moved and a composer whose SHA-256 equals the message's. Each capture
reads the screen and the caret in one tmux invocation. A frame showing another
writer's input, a new pane lifecycle or a new geometry ends the wait at once.
Every refusal after the keys went out (typing failed or was ambiguous, the
pane changed, or no frame verified in time) is a `409` that says the message
may already be in the composer: inspect the pane and follow the stranded-draft
recovery (Ctrl+U) in the skill reference before staging again.
While a guarded submit holds the input lease (the few milliseconds of its
conditional Enter), input that reaches the session is held, not refused, and is
written in arrival order as soon as the lease is released, whatever the submit's
outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged
only once it is written, so a frame typed during the lease lands before the one
typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval
answers and Codeman's own managed writes. A held write that is still being sent
holds everything behind it. A tagged `POST /api/v1/sessions/:id/input` (one
carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit
is in progress on this session; nothing was written") for that span, the lease
plus the held input's drain, rather than a `200` for bytes that were never
written; it keeps its seq, so the browser's durable queue, whose POST fallback
sends one frame at a time and keeps it on a `409`, delivers it on the next retry.
Untagged input to that route is held like a WebSocket frame, because its senders
(the phone accessory keys, the Stop button) never read the answer. A lease holds
at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and
nothing later from it is applied, so the browser reconnects and resends every
unacknowledged frame in order, and `send-key` and an untagged raw input answer
`409 CONFLICT`.
Direct auxiliary key operations participate in the same pending-write and input
lease checks. Complete allowlisted focus/mouse reports and empty writes preserve
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 and expiry, then waits until at least 250 ms have passed since
the staged keys went out (codex reads keys typed in one burst as a paste and, for
120 ms after one, turns Enter into a newline instead of a submit), then verifies
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
reuse or restored visible text. Direct tmux edits remain outside Codeman's
serialization. Failed, ambiguous, expired, changed or repeated submits return
`409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its
error recovery automatically replays Enter. Success acknowledges delivery of one
Enter, not native acceptance or task completion. Invalid schema input returns
`400 INVALID_INPUT`.
## The `codeman agent` CLI (client over these endpoints)
`codeman agent ls|spawn|send|wait|read|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It adds no route: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`, and adds no route for the other verbs: `spawn <case>` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir <dir>` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until <signals>`; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack <ids…>` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour.
`codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`.