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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user