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`.
+96 -2
View File
@@ -111,7 +111,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different
⚠️ `external`, `hooks` and `altScreen` are three INDEPENDENT capabilities on purpose; deriving one from another shipped the `until=stop`-hangs-on-shell bug.
⚠️ Four capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine` and `capabilities.workDetect.awaitingLine`) and all four must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
⚠️ Several capability fields carry a REGEX from config (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `watchingLine`, `awaitingLine`, `blockedLine`, `composerFooterLine`, `composerPopupLine` and `capabilities.modelDetect.screenLine`) and all of them must compile through `compileVersionRegex()`, which caps length and refuses nested quantifiers; `workingLine` is the one that runs on the PTY hot path.
⚠️ **`param` is TWO namespaces.** `launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a LAUNCH PARAM; the legacy `<Mode>Config` wire field is a separate namespace, bridged only by `launch.legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no load error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. Codex is the entry where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`) and therefore the one that catches a regression.
@@ -205,7 +205,7 @@ History: before the tree, every pair got a U-bridge bezier hanging below the str
**Three owners, one setter.** `SessionState.nameSource` is `placeholder` | `auto` | `manual`. The constructor infers a missing value from the name (`isGeneratedSessionName()` = `w<n>-<case>` / `s<n>-<case>`, or no name at all, is a placeholder; anything else was a person's), the create routes pass none, the boot restore passes the persisted one. The `name` setter is the manual path and the ONLY thing that produces `manual` after construction; `applyAutoName()` is the only thing that produces `auto`, and it does so whether or not the string changed, which is what makes "first prompt" mean the first. A prompt whose title is null (`/clear`, `! npm test`, blank) never reaches it, so the session stays eligible: the first REAL prompt names the tab.
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path and `POST /api/sessions/:id/input` set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
**The origin gate defaults to "system".** `write()` / `writeViaMux()` take `SessionWriteOptions.fromUser`; only the browser WS path, `POST /api/sessions/:id/input` and the experimental guarded stage (`stageGuardedInput()`, behind `POST /api/sessions/:id/stage`) set it. Every other caller (Ralph, respawn, cron, approvals, the orchestrator's `/compact`, auto-ops, the trust-dialog keys) is system by omission, so a new user-input path that forgets the flag fails toward a tab that keeps its placeholder, never toward a tab named after a Ralph prompt. `_lastSubmitAt` is still stamped for every write; only the tracker feed is gated. The shell gate is `getCli(mode)?.capabilities.startMode !== 'shell'`, a capability rather than an id check (the no-id-branching guard), and the send-key route feeds `trackUserInput()` by hand because its `tmux send-keys -H` newline (always a line feed for Ctrl+Enter; for Shift+Enter a line feed unless the CLI declares another `capabilities.newline` chord) never passes through the session.
**The tracker is a best-effort transcript with explicit per-key rules**, not a byte filter. Mirrored: printable text, backspace, Ctrl+W, Ctrl+U/Ctrl+C (composer emptied), `\n` and Alt+Enter (a newline IN the composer, joined with a space), bracketed paste (newlines inside it likewise). Ignored: cursor keys, Home/End/Delete, Shift+Tab, Tab, SGR mouse and focus reports, Alt chords, OSC/DCS, the rest of C0. Tainting: Up/Down (CSI and SS3), Ctrl+P/N/R, Ctrl+_, because the composer then holds a history line the tracker never saw and Enter must submit nothing rather than a fragment. A bare Esc is resolved at the END of the chunk it arrives in, since xterm hands each key's whole sequence to one write and the programmatic senders send Esc alone; a CSI split across chunks still resumes. The draft keeps its HEAD past 8192 code points (the title is the first sentence, so keeping the tail would title a long paste by its last line) and an escape sequence is abandoned past 64 bytes.
@@ -1264,3 +1264,97 @@ 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
These three operations are experimental across both CLI and HTTP, including the
`/api/v1` paths and unversioned aliases: a case of the "labeled experimental" rule
in `docs/versioning-policy.md`, so do not infer stability from the URL prefix.
⚠️ They are opt-in. All three routes answer `403 FORBIDDEN` with a message naming
`agentCoordinationEnabled` (synced, default OFF, App Settings, Cross-session
features) until it is on, the `cliManagementEnabled` / `mcpSyncEnabled` shape. The
gate runs first, before session lookup or body validation, and reads
`settings.json` fresh on every request, so a toggle applies without a restart.
`codeman agent inspect|stage|submit` pass the 403 through verbatim.
`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.
Cursors are capped per owner and session (`INSPECT_CURSORS_PER_SCOPE`, the newest 8,
under the global 1,024), so one polling agent retires its own oldest cursors instead
of evicting every other caller's from the shared FIFO.
⚠️ Codex never prunes `~/.codex/sessions`, so attribution must not cost a store
walk per call. The first call walks (negative-filter prefixes read 1 KiB first,
4 KiB only when that window holds no complete `originator` field; non-UUID file
names are skipped unread), then the attributed `{path, dev:ino}` and its strict
metadata are remembered per session (`attributionMemo`, bounded LRU). A later call
re-verifies only that file's head, then scans just the date directories not older
than its own (`sessions/YYYY/MM/DD`, compared numerically; any non-numeric name is
never pruned) for a second candidate, which is how a codex `/new` or a new owned
rollout beside a configured resume is still caught. A head that no longer verifies
drops the memo and falls back to one full walk; a verified head whose incremental
scan fails (a second candidate, an exhausted budget, an unreadable entry) drops it
and answers unattributed without one, because the full walk reads a superset of that
scan under the same budget and could only fail the same way. Non-`InspectionConflict` errors are `422
OPERATION_FAILED` (never a retryable `409`) and are logged with the session id and
error code only, never the path.
Staging requires a fresh, known composer/caret capture. The composer glyph and
footer are registry data (`capabilities.workDetect.promptGlyph` and
`composerFooterLine`, compiled through `compileVersionRegex()`), and only a CLI
declaring `composerFooterLine` takes guarded input (codex today). Codex's footer
words come from the same `CODEX_EFFORT_WORDS` as its `modelDetect.screenLine`. 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. A message
starting with a `composerCommandPrefixes` character or whose last word starts with a
`composerCompletionSigils` one is refused before typing (codex `/!` and `@$`), and a
frame with a `composerPopupLine` row in the block just above the composer is refused at
stage and submit: codex draws its popups there and keeps its footer, so the footer
alone read `› /stat` with the command list open as idle, and Enter ran `/status`.
⚠️ After typing, stage polls (`STAGE_SETTLE_TIMEOUT_MS` 1000 / `STAGE_SETTLE_INTERVAL_MS`
50) for the first frame with the same generation, `revision === before + 1` and
`composerSha === promptSha256(message)`, since codex repaints 30 to 60 ms after the keys
and the first capture is often from before them; another writer or a lifecycle change
ends the wait at once, and every refusal after typing says the message may be in the
composer. `capturePaneBuffer()` reads the visible frame and its caret in ONE tmux call
(`capture-pane \; display-message`, split by `splitFrameAndCursor()`), falling back to
the two calls only for output it cannot split; a combined call that THREW goes to the
capture's catch and is never rerun (a hung tmux would block the event loop for a second
timeout on every `GET .../terminal`). Keep the poll anyway. ⚠️ Input that arrives
while a guarded submit holds the lease is HELD, never refused (`holdForGuardedLease()`,
`_inGuardedTurn()`), and written in arrival order when the lease is released: the
browser's WebSocket path does not wait for ACKs, so a refused seq N was overtaken by
N+1 and its redelivery dropped as a duplicate. A WS frame is ACKed only once written;
a held async write (mux write, named key) holds everything behind it until it settles
(`guardedInputHeld`); a full hold queue (256 inputs / 1 MiB) closes the socket with
`1013` and applies nothing later from it, so the browser resends in order. `POST
.../input` answers `409` (seq returned) to TAGGED input for the lease plus the drain,
since its browser drainer is serialized and retries; untagged input there (the phone
accessory keys and the Stop button's Ctrl+C, fire-and-forget POSTs that never read the
answer) is held instead, the raw write through `holdForGuardedLease()` and a mux write
by `writeViaMux`'s own turn (never wrap `writeViaMux` in a hold: the drain would queue
it again behind later input and wait on itself). `write()` refuses during the lease
only, since the drain writes held input through it, so a direct `write()` caller such
as cron's `paste` mode (`cron-service.ts`) can land ahead of input still draining.
⚠️ Submit waits `SUBMIT_QUIET_MS` (250) after the staged keys before its
checks: stage types plain keys, codex's paste-burst detector reads them as a paste, and
an Enter within 120 ms of one becomes a newline while submit would report it
delivered. 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: they wait for the lease, and only a full hold queue
refuses them (`GuardedInputBusyError`, which `send-key` answers as `409 CONFLICT`). 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.
+33 -3
View File
@@ -47,9 +47,12 @@ interface CliEntry {
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines?, awaitingLine?,
// blockedLine?, blockedLines? }
// blockedLine?, blockedLines?, composerFooterLine?, composerPopupLine?,
// composerCommandPrefixes?, composerCompletionSigils? }
// (how this CLI's pane shows work, work it started in the background, a turn
// that ended waiting for workers it will resume from, and a dialog it is blocked on)
// that ended waiting for workers it will resume from, a dialog it is blocked on,
// the rows around its idle composer that guarded input relies on, and what a
// draft can trigger)
// .modelDetect?: { screenLine, screenLines? }
// (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
@@ -63,7 +66,7 @@ interface CliEntry {
### Regexes that come from config
Six capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine` and `capabilities.modelDetect.screenLine`. All six go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
Eight capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine` and `capabilities.modelDetect.screenLine`. All eight go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
@@ -124,6 +127,33 @@ below, open PERMISSION items, which a watching label never acknowledges), so a f
costs a wrong badge and cannot silence an alert. Before giving a CLI idle signals and a
pattern, make sure its row is one the agent cannot write.
### The idle composer guarded input reads (`composerFooterLine`)
The experimental guarded `stage`/`submit` routes (`src/agent-staging.ts`, opt-in through
`agentCoordinationEnabled`) type into a composer only after proving, from a fresh capture,
that the newest `promptGlyph` row is the CLI's own composer at rest, with the caret at the
exact end of its text. `composerFooterLine` is the second half of that proof: every
non-blank row below the composer (at most three) must match it, one row at a time. A CLI
that declares none never takes guarded input; only codex declares one, built from the same
effort-word list as its `modelDetect.screenLine` (`CODEX_EFFORT_WORDS` in `stock.ts`), so a
codex footer change is a one-place edit. Like `blockedLine`, the rows it sees are near the
foot of the screen, so a pattern must anchor on chrome only that CLI draws there.
Three more fields cover what a draft can do to Enter. Codex draws every suggestion popup
(its slash-command list, file search, skill and mention lists) ABOVE the composer and
leaves the footer below it alone, so the footer check reads `› /stat` with the command
list open as an idle draft, and Enter would run the highlighted `/status`.
`composerPopupLine` matches a popup row; it is tested against the contiguous block of
non-blank rows directly above the composer (past at most three blank rows, at most twelve
rows), never further up, and any match refuses the frame. `composerCommandPrefixes` lists
the characters that, first in a draft, make Enter run a command (codex: `/!`), and
`composerCompletionSigils` the ones that, starting the word under the caret, open a popup
(codex: `@$`); stage refuses such a message before typing, the sigils only on the last
word since the caret ends there. Both are 1 to 8 ASCII punctuation characters. Codex's
values were read from `codex-rs/tui/src/bottom_pane` at `rust-v0.162.0`; a file-search
popup with results shows bare path rows no pattern can tell from prose, which is why the
trigger refusal is there as well as the frame check.
### Dialogs a CLI blocks on (`blockedLine`)
`blockedLine` is the screen-read source of the `blocked` state for a CLI with no hook to
+19
View File
@@ -62,6 +62,25 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump:
anything labeled experimental in the UI or docs. These may change or be
removed at any time.
### Experimental agent coordination
`codeman agent inspect`, `codeman agent stage` and `codeman agent submit`, and
the matching `GET /api/v1/sessions/:id/inspect`, `POST /api/v1/sessions/:id/stage`
and `POST /api/v1/sessions/:id/submit` routes (with their `/api` aliases, since
every `/api` route is also served under `/api/v1`), are labeled experimental, so
item 3 above applies to them: their commands, flags, endpoint paths, payloads and
behavior may change or be removed in a MINOR or PATCH release. They are also
opt-in: the routes answer `403 FORBIDDEN` with a message naming the synced
`agentCoordinationEnabled` setting (default OFF) until it is turned on, so
automation cannot come to depend on them without choosing to. Pin a compatible
Codeman version when automating them. Existing authentication, ownership and
shared response-envelope rules still apply. The session behavior underneath them
(input revision tracking in the session's write paths, the SubmitVerifier
re-press going through the shared write boundary) runs for every session and is
covered like any other stable behavior. Other agent commands and API routes keep
their existing guarantees. Promotion to stable requires an explicit documented
decision.
## Deprecation policy
When we need to change a covered surface:
@@ -45,6 +45,9 @@ SID=$(codeman agent spawn --working-dir ~/src/app --mode codex) # no case: an
codeman agent send "$SID" 'review src/, then say DONE' --until stop,exit --timeout 300000
codeman agent read "$SID" # last answer (as the server reads it for that mode)
codeman agent read "$SID" --tail 3000 # terminal tail, ANSI stripped (every mode)
codeman agent inspect "$SID" --max-bytes 8192 --json # experimental, opt-in: recent evidence + opaque cursor
codeman agent stage "$SID" --message-file /tmp/prompt.txt --json # experimental, opt-in: no Enter; local Codex composer only
codeman agent submit "$SID" --stage-id "$STAGE_ID" --confirm-sha "$SHA256" --json # experimental, opt-in: one conditional Enter
codeman agent send "$SID" 'run the tests, then print WORKDONE followed by _4711' # hook-less modes: the marker in halves …
codeman agent wait "$SID" --match WORKDONE_4711 # … and the wait on the joined form
codeman agent interrupt "$SID" # a bare ESC, conversation intact
@@ -83,6 +86,17 @@ codeman agent rm "$SID" # any session except t
`ls` shows each session's mail in an INBOX column. Mail lives in memory unless
**Keep agent inbox across restarts** is on (see [Settings Reference](Settings-Reference)),
and goes when its session is deleted.
- **`inspect`, `stage`, `submit`** are experimental and opt-in: they answer `403 FORBIDDEN`
naming the setting until **Experimental agent coordination** is on (see
[Settings Reference](Settings-Reference); `agentCoordinationEnabled`, synced, off by
default), and they may change in any release. `inspect` returns bounded evidence with an
opaque cursor. `stage` types one printable UTF8 line without trailing whitespace into a
local Codex composer, with no Enter, and returns a receipt that expires after two minutes
once the pane shows exactly that text; it refuses slash (`/`) and shell (`!`) commands
and a message whose last word starts with `@` or `$` (those open a Codex popup that Enter
would accept), so send commands with `send`. `submit` sends one Enter only if the
composer still shows exactly that text, and never replays it. `send --no-enter` alone has no receipt. Details:
[API reference](https://github.com/Ark0N/Codeman/blob/master/docs/api-reference.md#experimental-bounded-agent-inspection-and-guarded-input).
- **Exit codes** are the same for every verb: `0` done, `1` error, `2` timeout, `3` the
worker exited, `4` refused. `--json` prints the response's `data`, except a directory
`spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a
+8
View File
@@ -124,6 +124,14 @@ memory and a restart empties it; switching it off deletes the file and keeps wha
memory. Messages expire 12 hours after they were posted either way. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
**Experimental agent coordination** (under **Cross-session features**, synced, off by
default) turns on the `codeman agent inspect`, `stage` and `submit` commands: bounded,
paged reads of another session's output, and typing a prompt into a local Codex composer
that is sent with one Enter only after Codeman checks it is unchanged. Off, their HTTP
routes answer `403 FORBIDDEN` naming the setting (`agentCoordinationEnabled`). They are
experimental and may change in any release. See
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent#the-codeman-agent-commands).
### Appearance
| Setting | Notes |