mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
commit
5908cc51fd
50 files changed
+5408
-116
No files matched your search
+162
-1
@@ -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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user