mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Seven things, none of which changes what the feature does. 1. The rebuilt Session dropped `nameSource`, so the constructor re-inferred it from the name: a session the user renamed by hand to something shaped like `w<n>-<case>` came back as `placeholder`, and with auto-naming on the next prompt overwrote their name. The route persists right after, so the loss went to disk. `restoreMuxSessions()` already passes it. 2. The already-live sets were snapshotted once before a loop that awaits a real `startInteractive()` per entry, so by the tenth entry the snapshot was tens of seconds old and a conversation resumed by hand from the Resume list in that window was invisible to it: two panes on one transcript, the exact thing the check exists to prevent. Both sets are now read per iteration, and the late case is spent rather than re-offered for the same reason the batch case is. 3. Auto-resume no longer re-arms the pre-reboot `autoResumeAt` on this path. The stamp predates the reboot and the pane is new, so honouring it meant one click had every restored session type `continue` into itself about a minute later, unattended, against the route header's own promise that a restored session comes back idle and disarmed. The setting stays ENABLED, so it re-arms on the next real limit message. A Codeman restart still re-arms from the stamp, because the limit footer will not reprint on its own; the new option exists only to tell the two paths apart. 4. `discardPartiallyBuiltSession()` now also calls `recordSessionStopped()` and `ralphTracker.fullReset()`, the two teardown steps `_doCleanupSession` performs that it was missing. Cosmetic, but a run left open reads as still going in the away digest. 5. A restored claude session gets `seedAgentSessionPreamble()` like both create paths, so the agent skill's bootstrap stays a two-line loader. 6. The heuristic's container comment was wrong in one direction and quiet about the real gap: after a genuine host reboot a containerized Codeman sees the host's short uptime and the banner does appear. What it cannot see is a container-only restart, which is where this would help most. 7. The banner is hidden in a solo window, which shows one session and has no tab strip to put restored ones in. Also reverts 17 of the 18 hunks in docs/api-reference.md, which were Prettier reformatting of prose the PR does not otherwise touch (docs/ is outside the format glob), keeping only the Reboot restore section and repairing the two continuation lines that reformat de-indented; renumbers reboot-restore-ui.js to @loadorder 11.65, since 11.7 is admin-ui.js, which loads after it; and gives the feature its CLAUDE.md entry plus a route test for the multi-user workspace-forbidden branch, the only new rule that had nothing behind it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
628 lines
36 KiB
Markdown
628 lines
36 KiB
Markdown
# HTTP API Reference
|
||
|
||
Codeman's HTTP API is a **stable contract** as of 1.0 — see
|
||
[`versioning-policy.md`](versioning-policy.md) for the SemVer guarantee. This page
|
||
defines the response envelope, status codes, error codes, versioning, and the SSE
|
||
event channel.
|
||
|
||
## Versioning
|
||
|
||
- The stable, public surface is served under **`/api/v1/...`**. Pin external
|
||
clients to this prefix.
|
||
- The unversioned **`/api/...`** paths are a permanent alias of the current
|
||
version (what the bundled web UI uses). They are kept working, but new external
|
||
integrations should use `/api/v1`.
|
||
- Breaking changes to the contract ship under a new prefix (`/api/v2`); `/api/v1`
|
||
keeps its semantics. Additive changes (new endpoints, new optional fields, new
|
||
error codes) are non-breaking and may appear in a minor release.
|
||
- The implementation rewrites `/api/v1/*` → `/api/*` at the server level
|
||
(`rewriteApiV1Url` in `src/web/server.ts`).
|
||
|
||
## Response envelope
|
||
|
||
Every JSON response uses one uniform envelope, applied centrally by a
|
||
`preSerialization` hook (`src/web/server.ts`) — handlers return bare data and the
|
||
hook wraps it:
|
||
|
||
**Success** — HTTP `2xx`:
|
||
|
||
```json
|
||
{ "success": true, "data": <payload> }
|
||
```
|
||
|
||
`data` is the endpoint's payload (object, array, or value). Endpoints with no
|
||
payload return `{ "success": true, "data": {} }`.
|
||
|
||
**Error** — HTTP `4xx`/`5xx`:
|
||
|
||
```json
|
||
{ "success": false, "error": "human-readable message", "errorCode": "NOT_FOUND" }
|
||
```
|
||
|
||
`ApiResponse<T>` in `src/types/api.ts` is the canonical type.
|
||
|
||
> Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`,
|
||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||
|
||
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
||
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
||
> to 600 s. Proxy operators and HTTP clients with a global read timeout need to know
|
||
> that before pointing them at Codeman.
|
||
|
||
⚠️ **A `401` is the one status that is not an envelope.** Authentication is rejected
|
||
in a request hook, before any handler runs, and it replies with the bare string
|
||
`Unauthorized` (`Unauthorized: hook secret required` on the hook path) plus
|
||
`WWW-Authenticate: Basic realm="Codeman"`. There is no `success`, no `error`, and no
|
||
`errorCode`, because the wrapping hook only wraps object payloads. So a client that
|
||
pipes every response straight into a JSON parser dies with a parse error rather than
|
||
reporting an auth failure, which is a confusing way to discover that a password is
|
||
set. Branch on the HTTP status **before** parsing.
|
||
|
||
## Error codes → HTTP status
|
||
|
||
The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in
|
||
`src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on
|
||
the HTTP status.
|
||
|
||
| `errorCode` | HTTP | Meaning |
|
||
|-------------|------|---------|
|
||
| `INVALID_INPUT` | 400 | Malformed request / failed validation |
|
||
| `UNAUTHORIZED` | 401 | Authentication required or failed |
|
||
| `NOT_FOUND` | 404 | Resource does not exist |
|
||
| `SESSION_BUSY` | 409 | Session is busy |
|
||
| `CONFLICT` | 409 | Conflicts with current state (e.g. already running) |
|
||
| `ALREADY_EXISTS` | 409 | Resource already exists |
|
||
| `OPERATION_FAILED` | 422 | Well-formed but could not be completed |
|
||
| `RATE_LIMITED` | 429 | Too many requests |
|
||
| `INTERNAL_ERROR` | 500 | Unexpected server error |
|
||
|
||
Adding a new error code is non-breaking; removing or renaming one is a major change.
|
||
|
||
## Long-polling (agent wait)
|
||
|
||
Three calls block until something happens instead of answering immediately. They
|
||
exist because SSE is Codeman's only other "tell me when" channel, and an agent
|
||
driving the API from a shell tool cannot practically hold a stream and parse
|
||
events inline.
|
||
|
||
| Call | Blocks until |
|
||
|------|--------------|
|
||
| `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires |
|
||
| `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output |
|
||
| `POST /api/v1/sessions/:id/input` with `wait` | the input is delivered **and then** a signal fires |
|
||
|
||
`POST .../input` with `wait` is not the same as a `POST` followed by a separate
|
||
`GET .../wait`. It registers the waiter **before** writing, which closes the window
|
||
in which a separate wait sees the session still idle from the previous turn and
|
||
answers instantly with the wrong turn's result. Use it whenever you send a prompt
|
||
and want to know when that prompt is done.
|
||
|
||
### Three semantics that break callers who assume otherwise
|
||
|
||
**1. A timeout is HTTP `200`, not an error.** A wait that ends without its signal
|
||
returns `{"success":true, ...,"wait":{"timedOut":true,"signal":null}}`. The
|
||
intended pattern is a client-side loop over short waits, because `tailscale serve`
|
||
and cloudflared can both cut an idle connection, and turning every poll boundary
|
||
into a `4xx` would make that loop indistinguishable from a real failure. `408` is
|
||
auto-retried by several clients (silently doubling the polling load), `504` is what
|
||
a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status` /
|
||
`limitPaused`. Reserve error handling for the four codes in the table below.
|
||
|
||
**2. `stop` and `blocked` fire only for `claude` sessions.** Both come from Claude
|
||
Code hooks, and no other mode installs them: `shell` runs no agent, and the external
|
||
CLIs (`opencode`, `codex`, `gemini`, `antigravity`, `pi`) render their own TUIs and post
|
||
no hooks. For every non-`claude` mode only `idle`, `working` and `exit` are
|
||
accepted, and of those only `exit` is dependable: see the caveats under
|
||
[Signals](#signals) before building on `idle`. Requesting `stop` or `blocked`
|
||
**explicitly** on such a session is a
|
||
`400`; omitting `until` never fails, the server just drops them from the default set
|
||
and echoes the narrowed set back as `wait.until`. Three more places hooks can go
|
||
missing even in `claude` mode: a **Docker case** needs
|
||
`CODEMAN_DOCKER_BRIDGE_HOOKS=1`, since a container cannot reach a loopback-bound
|
||
Codeman (without it, only `idle` / `working` / `exit` work); a **remote-SSH
|
||
case** runs the agent on another host, whose hooks may never reach this server at
|
||
all; and a case whose hook config was written by **Codeman < 1.13.0 against an
|
||
`--https` install** carries hook curls without `-k`, which TLS-fail silently (the
|
||
hook line ends in `|| true`). Codeman now writes `curl -sk` and repairs a stale
|
||
case config the next time a session starts in that case. When in doubt, ask for
|
||
`stop,idle,exit` so a session without hooks still resolves on the heuristic
|
||
signal.
|
||
|
||
**3. `from=now` does not mean "printed after you asked".** tmux repaints the visible
|
||
screen on attach, on resize, and on any TUI redraw, and a repaint arrives as
|
||
ordinary output, so text that was already on screen can satisfy a fresh wait. This
|
||
was observed live: a marker echoed a minute earlier matched instantly on a new
|
||
`from=now` wait. It is inherent to running the agent under a multiplexer, so the
|
||
contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send
|
||
`echo $MARK`, then wait on `$MARK`), never a generic string like `BUILD OK`.
|
||
|
||
### Signals
|
||
|
||
| Signal | Source | Actually fires for |
|
||
|--------|--------|--------------------|
|
||
| `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `shell`: **once only**, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization) |
|
||
| `working` | the session's own `working` event | `claude` only in practice (spinner and work-keyword detection are Claude output formats) |
|
||
| `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` only |
|
||
| `blocked` | a `permission_prompt` or `elicitation_dialog` hook | `claude` only, and rarer than it looks: see below |
|
||
| `exit` | no process is behind the session | every mode |
|
||
|
||
`stop` is the signal to orchestrate on where it exists; `idle` is a heuristic
|
||
fallback that can flap mid-turn when a spinner pauses. The default set when `until`
|
||
is omitted is `stop,idle,exit` (`exit` is in there so a worker that crashes resolves
|
||
the wait promptly instead of burning the caller's whole timeout on something that
|
||
can no longer happen). On a `claude` worker, prefer an explicit `until=stop,exit`
|
||
once the session is up: the default set's `idle` also resolves on a spinner pause,
|
||
and on a fresh session the **startup** `idle` (emitted when the CLI first comes up)
|
||
can land inside your first wait window and report a turn that never ran. Measured:
|
||
a session parked on the trust dialog emits no *further* `idle`, so it is the
|
||
startup transition, not the dialog, that produces the false success below.
|
||
|
||
⚠️ **`exit` means "nothing is running", which includes "not started yet".** The
|
||
server answers from `pid === null` plus a mux-layer pane-death probe, and that
|
||
covers a session that exited — including a worker that died *inside* its tmux pane
|
||
while the local attach client (and therefore `pid`) lives on — one that was
|
||
detached, and one that was **created but never started**. So the first wait
|
||
after `POST /api/v1/sessions` returns `{"signal":"exit","immediate":true}` in
|
||
milliseconds, and reading that as "the worker died" is wrong: it means start it, or
|
||
wait for it to come up. `status` is carried alongside so nothing is hidden. The
|
||
alternative (trusting `status`) is worse, because a dead PTY parks the session at
|
||
`status: "idle"`, which would answer the default wait with `immediate: true` for a
|
||
worker that has crashed. A worker dying while a wait is parked resolves it within
|
||
a few seconds (a background death-watcher), not at the timeout.
|
||
|
||
⚠️ **`blocked` is reachable less often than the table suggests.** It fires on two
|
||
hooks, and the default configuration suppresses one of them: Codeman spawns claude
|
||
with `--dangerously-skip-permissions`, so permission prompts do not happen unless the
|
||
instance is switched to the `auto` Claude mode (App Settings), or the caller is a
|
||
multi-user account without the bypass grant, which is forced to `--permission-mode
|
||
auto`. What does still fire under the default is `elicitation_dialog`, the agent
|
||
asking the user a question. So `until=stop,blocked,exit` is a reasonable belt on a
|
||
long turn, but a worker that never comes back is far more likely to be working than
|
||
blocked, and polling `blocked` alone will sit at its timeout.
|
||
|
||
⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell
|
||
session emits its one `idle` at startup and then stays `status: "idle"` forever,
|
||
whatever the pane is doing, so it never emits a *transition*. Since send-and-wait
|
||
requires a transition (and so does `fresh=1`), both can only time out there:
|
||
a documented default `wait` on a shell worker running `sleep 4` times out at the
|
||
full 25 s. Synchronize hook-less sessions with `wait-output` and a unique marker
|
||
instead. The same caution applies to the external CLIs.
|
||
|
||
### Readiness is not a signal
|
||
|
||
Nothing here reports "the agent is ready for a prompt", and no combination of
|
||
`until`/`fresh` synthesizes one. A freshly created session reads as `exit` (above),
|
||
and a `claude` worker in a brand-new case comes up on the CLI's **trust dialog**,
|
||
which contains a ❯ prompt of its own. Send-and-wait posted at that moment types the
|
||
prompt into the dialog, where the `\r` never gets past it, while the session's
|
||
startup `idle` lands inside the wait window: the wait resolves on `idle` in a
|
||
couple of seconds with `timedOut: false`, which looks exactly like a completed
|
||
turn.
|
||
|
||
The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is
|
||
non-null, then `wait-output` for the composer's own marker (`bypass`, the status
|
||
bar of a CLI spawned in bypass mode) with a short timeout, handling the trust
|
||
dialog only as the bounded fallback.
|
||
|
||
⚠️ **The fallback is not a bare `\r`.** Claude Code 2.1.252 unnumbered the dialog's
|
||
options, reversed them and highlights `No, exit`, so an Enter sent blind quits the
|
||
CLI and the pane dies seconds after the spawn. Read the `❯` marker off the current
|
||
frame (`GET /api/v1/sessions/:id/terminal?full=1`), send `ESC [ B` while it is on
|
||
`No, exit`, re-read, and confirm only once it is on `Yes, I trust this folder`.
|
||
Reading the current frame is also what keeps this correct on later runs: the dialog
|
||
text stays in the terminal buffer for the life of the session, so a `trust` probe
|
||
with `from=buffer` keeps matching long after the dialog is gone. A worked version is in
|
||
[`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli).
|
||
|
||
### `GET /api/v1/sessions/:id/wait`
|
||
|
||
| Param | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback |
|
||
| `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` |
|
||
| `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time |
|
||
|
||
```bash
|
||
curl -s "$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000"
|
||
```
|
||
|
||
Both GET wait routes answer with `Cache-Control: no-store`, because the documented
|
||
pattern polls one identical URL in a loop and a cached `{"timedOut":true}` would
|
||
turn that loop into a busy spin. `POST .../input` sends no cache header (it is a
|
||
POST, which is not heuristically cacheable).
|
||
|
||
⚠️ **Unknown query parameters are ignored, not rejected**, with one exception
|
||
(`regex`, below). In particular `match=` on `/wait` is silently dropped and you get
|
||
a plain signal wait, so check the endpoint path before blaming the parameters.
|
||
|
||
### `GET /api/v1/sessions/:id/wait-output`
|
||
|
||
| Param | Type | Default | Notes |
|
||
|-------|------|---------|-------|
|
||
| `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found |
|
||
| `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing |
|
||
| `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking |
|
||
| `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` |
|
||
|
||
**Matching is literal, never a pattern.** A `regex` parameter is rejected with a
|
||
`400` rather than ignored, so a caller that assumed otherwise finds out immediately
|
||
instead of waiting on the wrong thing. The reasoning is in
|
||
[`architecture-invariants.md`](architecture-invariants.md#agent-wait-primitives).
|
||
|
||
#### What the matcher actually sees
|
||
|
||
The matcher scans the raw PTY stream, **normalized**: ANSI escape sequences are
|
||
stripped — CSI, OSC, and the charset-designation escapes a stock bash prompt emits
|
||
on every line (`ESC ( B`), so `match=tnode:` matches a prompt that renders
|
||
`…@tnode:` — a partial escape arriving at a chunk boundary is held back until its
|
||
tail arrives, and a match may straddle PTY chunks: `printf STRAD; sleep 1; printf
|
||
DLEQQ` is matchable as `STRADDLEQQ` (all measured live). Three caveats remain:
|
||
|
||
⚠️ **It is still the byte stream, not the rendered pane.** `GET .../terminal`
|
||
answers from a tmux screen capture (`data.source: "mux-visible"`), the finished
|
||
picture; the matcher sees the stream that painted it. For linear output the two
|
||
agree once escapes are stripped, but a full-screen TUI composes its picture with
|
||
cursor positioning, so what the pane shows and what the stream carries can differ.
|
||
Seeing your string in `terminal?tail=` makes a match likely, not guaranteed.
|
||
|
||
⚠️ **A TUI's text can arrive without its spaces.** Claude Code positions words
|
||
with cursor moves rather than printing spaces, so screen text can reach the
|
||
matcher as `Quicksafetycheck:Isthisaprojectyoucreated...`. Whether a given phrase
|
||
keeps its spaces depends on how the TUI happened to draw it (measured: `I trust
|
||
this folder` matched, `Quick safety check` did not), so a multi-word `match`
|
||
against a TUI pane is unreliable rather than impossible. Match a **single
|
||
space-free token**, ideally one you printed yourself. Plain command output (a
|
||
shell worker, an `echo`) keeps its spaces.
|
||
|
||
⚠️ **The returned `snippet` is a rendering of the matched text, not a quotation of
|
||
it.** It is cut from the same normalized stream the match ran against, then
|
||
cleaned for display: remaining raw control bytes are removed (an agent pipes the
|
||
snippet into its own terminal, so a worker's bytes must not be able to reset that
|
||
display) and blank runs are collapsed. A printable needle that matched will appear
|
||
in it; a needle containing control bytes or a blank run may not survive verbatim.
|
||
|
||
```bash
|
||
MARK="DONE_$RANDOM"
|
||
curl -sG "$API/api/v1/sessions/$SID/wait-output" \
|
||
--data-urlencode "match=$MARK" --data-urlencode 'timeout=120000'
|
||
```
|
||
|
||
Build the query with `-G --data-urlencode` rather than by hand: a `+` in a
|
||
hand-written query string decodes to a space.
|
||
|
||
### `POST /api/v1/sessions/:id/input` with `wait`
|
||
|
||
Two optional fields on the existing endpoint:
|
||
|
||
| Field | Type | Notes |
|
||
|-------|------|-------|
|
||
| `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" |
|
||
| `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` |
|
||
|
||
Both are `nullish`, so an explicit `null` from `JSON.stringify` is accepted as
|
||
"absent" rather than failing validation. That is deliberate: `.optional()` would
|
||
reject it, which has shipped as a real bug twice.
|
||
|
||
The input must end with `\r` (a real carriage return in the JSON string): Enter is
|
||
sent only when the input contains one, so text without it is typed onto the
|
||
worker's prompt but never submitted, and the wait then runs its full timeout on a
|
||
turn that never started. Verified live; this is the most common silent failure on
|
||
this endpoint.
|
||
|
||
```bash
|
||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"input":"run the tests\r","useMux":true,"clientId":"agent-1","seq":1,
|
||
"wait":"stop","waitTimeout":600000}'
|
||
```
|
||
|
||
A **tagged duplicate** (a `clientId` + `seq` pair the server has already applied)
|
||
still honors `wait`, because the caller's question is unanswered, but it answers
|
||
from the session's current state rather than requiring a new transition: the
|
||
original turn may be long over. It comes back as
|
||
`"delivered": false, "duplicate": true`.
|
||
|
||
### Response
|
||
|
||
All three nest the wait result under `data.wait`, so one client helper works against
|
||
any of them:
|
||
|
||
```json
|
||
{ "success": true, "data": {
|
||
"sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464",
|
||
"status": "idle",
|
||
"limitPaused": false,
|
||
"wait": {
|
||
"signal": "stop", "until": ["stop", "idle", "exit"],
|
||
"timedOut": false, "immediate": false, "ended": false, "aborted": false,
|
||
"waitedMs": 8421, "timeoutMs": 60000
|
||
}
|
||
}}
|
||
```
|
||
|
||
`POST .../input` returns the same `wait` object alongside `delivered`, `duplicate`,
|
||
`status` and `limitPaused`. `POST .../input` **without** `wait` is unchanged and
|
||
still returns `{"success": true, "data": {}}`.
|
||
|
||
⚠️ `delivered: false` has **two** meanings, and they must be told apart by
|
||
`duplicate`: with `duplicate: true` the input was suppressed as an already-applied
|
||
redelivery (harmless, the turn it refers to may be long over), while with
|
||
`duplicate: false` the **write failed** (typically no PTY behind the session). A
|
||
client that reads `delivered === false` as "duplicate" silently treats a failed send
|
||
as a success.
|
||
|
||
| Field | Type | Meaning |
|
||
|-------|------|---------|
|
||
| `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) |
|
||
| `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) |
|
||
| `wait.matched` | boolean | the string appeared (`/wait-output` only) |
|
||
| `wait.match` | string | the literal that was searched for (`/wait-output` only) |
|
||
| `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) |
|
||
| `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` |
|
||
| `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) |
|
||
| `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met |
|
||
| `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome |
|
||
| `wait.waitedMs` | number | wall-clock ms actually spent waiting |
|
||
| `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied |
|
||
| `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand |
|
||
| `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard |
|
||
|
||
Read the outcome by discriminator, in this order:
|
||
|
||
1. `wait.signal !== null` (or `wait.matched === true`): the thing happened.
|
||
2. `wait.timedOut`: a poll boundary. Loop again.
|
||
3. `wait.ended` or `wait.aborted`: the wait answered nothing, because the session is
|
||
gone or was never running. Re-check the session instead of looping.
|
||
|
||
`wait.immediate` is not a fourth outcome: it rides along with the first one and
|
||
means the condition already held at call time, so nothing was actually waited for.
|
||
If that is not what you meant, you wanted `fresh=1` or the send-and-wait form. Note
|
||
that `{"signal":"exit","immediate":true}` on a session you just created is the
|
||
not-started-yet case, not a crash.
|
||
|
||
**The timeout is clamped, so read it back.** A request for 1800000 ms is silently
|
||
reduced to the server's ceiling (600000 ms by default, operator-tunable), and a
|
||
request for 1 ms is raised to 1000 ms. `wait.timeoutMs` is the value that was
|
||
applied. Without checking it, a caller that asked for 30 minutes and got 10 will
|
||
read the timeout as "the worker is wedged" and kill a session that was working fine.
|
||
|
||
### Errors
|
||
|
||
| `errorCode` | HTTP | When |
|
||
|-------------|------|------|
|
||
| `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that installs no hooks (the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` |
|
||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
|
||
| `SESSION_BUSY` | 409 | this session's waiter cap is full |
|
||
| `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem |
|
||
|
||
The two capacity codes are deliberately different. A process-wide cap reported as
|
||
`SESSION_BUSY` would tell the caller to switch sessions, which cannot help. The
|
||
error message names the cap that was hit.
|
||
|
||
⚠️ A `401` is **not** in this table and is not an envelope at all (see
|
||
[Response envelope](#response-envelope)). It matters most here: a polling loop that
|
||
pipes each wait straight into `jq` fails with a parse error on every iteration
|
||
against a password-protected server, which reads as "the wait endpoints are broken".
|
||
Check the status first.
|
||
|
||
The per-session cap is a **combined** budget: signal waiters and output waiters
|
||
count against the same 16, not 16 of each. An abandoned request no longer holds its
|
||
slot, because the routes release the waiter when the client disconnects, but a
|
||
client that opens many concurrent waits against one session will still hit the cap.
|
||
|
||
## Session lineage (`parentSessionId`)
|
||
|
||
A create request may name the session that spawned it, which the web UI draws as a
|
||
line between the two tabs. Accepted on `POST /api/v1/sessions` and
|
||
`POST /api/v1/quick-start`, either way:
|
||
|
||
```bash
|
||
# as a body field
|
||
-d '{"caseName":"worker-1","mode":"claude","parentSessionId":"'"$CODEMAN_SESSION_ID"'"}'
|
||
|
||
# or as a header, which is what an agent driving many spawns should use: set it once
|
||
# on the curl invocation and every spawn call carries it
|
||
-H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID"
|
||
```
|
||
|
||
The body field wins if both are present. The value is resolved against live sessions
|
||
(exact id, or a unique prefix of at least 8 characters) and must belong to the same
|
||
owner as the session being created.
|
||
|
||
**It cannot fail your spawn.** An unknown, stale, foreign or malformed value is
|
||
silently dropped and the session is created without lineage — never a `400`. It is
|
||
also pure decoration: it confers no permission, and a child is unaffected by its
|
||
parent exiting. It appears on session state as `parentSessionId` (absent when
|
||
unresolved) and survives a server restart.
|
||
|
||
## Approvals Inbox
|
||
|
||
Cross-session queue of prompts waiting on a human (permission dialogs,
|
||
AskUserQuestion questions, idle prompts). Claude-mode sessions only; items are
|
||
in-memory (a server restart drops them; the next prompt re-fires the hook).
|
||
Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
|
||
|
||
- `GET /api/v1/approvals` → `{ approvals: ApprovalItem[] }`, oldest first,
|
||
ownership-scoped in multi-user mode. `ApprovalItem`: `{ id, sessionId,
|
||
sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?,
|
||
toolSummary?, message?, cwd?, context?, options?: {n, label}[],
|
||
acknowledgedAt? }`. `context` is the ANSI-stripped visible pane frame;
|
||
`options` is present only when the dialog's numbered choices parsed
|
||
confidently; `acknowledgedAt` marks an item a human has already looked at
|
||
(see `/viewed` below) and tells clients not to re-arm its tab alert. Listing
|
||
also runs a staleness sweep over the caller's own items: the pane is
|
||
re-captured, and an item whose dialog no longer parses is resolved as
|
||
`resolved_in_terminal` instead of being returned (only items whose original
|
||
frame parsed `options` can be dropped this way, so an unreadable capture
|
||
keeps the item).
|
||
- `POST /api/v1/approvals/:id/answer` with `{ action: 'approve' }` (sends the
|
||
digit `1`), `{ action: 'deny' }` (sends Esc), `{ action: 'option', option: n }`
|
||
(sends the digit; accepted only when `n` is among the item's parsed
|
||
`options`), or `{ action: 'text', text }` (idle prompts only; submits the
|
||
line as a prompt). `404 NOT_FOUND` when the item is no longer pending,
|
||
`409 CONFLICT` when the dialog left the screen or another actor answered
|
||
first, `422 OPERATION_FAILED` when the session refused input.
|
||
- `POST /api/v1/approvals/:id/dismiss` removes the item without keystrokes.
|
||
- `POST /api/v1/approvals/session/:sessionId/viewed` → `{ sessionId,
|
||
acknowledged: itemId | null }`. Marks the session's pending **idle** item as
|
||
seen by a human (the web UI calls it when you open the session's tab): the
|
||
item stays pending and answerable, but stops arming the yellow tab alert on
|
||
every client, including after a reload. Permission/question items are never
|
||
acknowledged this way, since looking at a dialog does not answer it. `404`
|
||
for an unknown or inaccessible session; acknowledging twice is a no-op
|
||
(`acknowledged: null`).
|
||
|
||
SSE events: `approval:pending` (full item), `approval:updated` (context/options
|
||
re-captured, or the item acknowledged), `approval:resolved` (`{ id, sessionId, kind, resolution }` with
|
||
`resolution` one of `answered | resolved_in_terminal | superseded |
|
||
session_ended | dismissed | expired`).
|
||
|
||
## Reboot restore
|
||
|
||
A host reboot takes the tmux server down with it, so every pane dies and the
|
||
board comes up empty. At boot Codeman works out which sessions the reboot
|
||
destroyed and holds that plan in memory, and these endpoints let a client offer
|
||
it to the user. Nothing creates a pane until the user asks: the boot-time reboot
|
||
heuristic decides whether to ASK, never whether to act.
|
||
|
||
Claude-mode sessions only (others carry their conversation id in their own
|
||
config object); remote and docker sessions are never offered, because both need
|
||
another host or container to be up. The plan is in-memory, so a server restart
|
||
drops it and the offer is gone; the conversations themselves are unaffected,
|
||
since they live in the CLI's own transcript store and stay reachable from the
|
||
Resume list. A plan nobody spends expires after 24 hours.
|
||
|
||
- `GET /api/v1/reboot-restore` → `{ sessions: RestorableSession[],
|
||
scrollbackRestored: false }`, ownership-scoped in multi-user mode.
|
||
`RestorableSession`: `{ id, name?, workingDir, mode, owner? }`. The persisted
|
||
record itself is never sent. `scrollbackRestored` is always `false` and exists
|
||
so a client states it: a restored session is a NEW pane, so the conversation
|
||
continues and the terminal history does not.
|
||
- `POST /api/v1/reboot-restore/restore` with `{ sessionIds?: string[] }` (omit
|
||
to restore everything the caller can see) → `{ restored: RestorableSession[],
|
||
skipped: { sessionId, reason }[] }`. `reason` is one of `workspace-missing`
|
||
(the directory is gone), `workspace-forbidden` (in multi-user mode it is
|
||
outside the workspace of the user the session belongs to, re-checked against
|
||
that owner's current grant rather than the caller's), `already-live` (the conversation is already
|
||
open, typically resumed by hand from the Resume list), `capacity-reached`
|
||
(the global or per-user session cap), or `rebuild-failed` (the agent would not
|
||
start, most often a CLI binary missing from the server's PATH).
|
||
`409 CONFLICT` when that caller already has a restore running. Entries are
|
||
removed from the plan before any pane is built, so a double-click cannot put
|
||
two panes on one conversation; anything that never became a pane goes back on
|
||
offer, except `already-live`, which cannot stop being true. A restored session
|
||
comes back attached, idle and disarmed: respawn controllers and Ralph loops
|
||
are never re-armed automatically.
|
||
- `POST /api/v1/reboot-restore/dismiss` → `{ dismissed: n }`. Drops the offer
|
||
for everything the caller can see.
|
||
|
||
Each rebuilt session also emits the ordinary `session:created` SSE event, so
|
||
clients other than the one that clicked pick it up without refetching.
|
||
|
||
## Read My Mind intent profiles
|
||
|
||
Per-case profiles of what the user is trying to accomplish: user/agent-stated
|
||
goals plus the user's recently submitted prompts, captured from the Claude
|
||
session transcript while the opt-in `readMyMindEnabled` setting is on (default
|
||
OFF). Keyed by owner + workingDir, so the profile survives `/clear`, respawns,
|
||
and session churn. Stored in `~/.codeman/intents.json` (mode 0600); never fed
|
||
into `/api/v1/search`. Design: [`readmymind-plan.md`](readmymind-plan.md);
|
||
user guide: [`readmymind.md`](readmymind.md).
|
||
|
||
- `GET /api/v1/sessions/:id/intent` -> `{ intent: IntentProfile }` for the
|
||
session's case. `IntentProfile`: `{ key, workingDir, updatedAt, goals,
|
||
recentPrompts: { ts, sessionId, text }[] }` (prompts oldest first, FIFO cap
|
||
50, each <= 500 chars). A case with nothing recorded answers an empty
|
||
profile with `updatedAt: 0`; nothing is persisted by reads.
|
||
- `PUT /api/v1/sessions/:id/intent` with `{ goals }` (<= 8192 chars, strict
|
||
schema) replaces the goals text and answers the updated profile.
|
||
`400 INVALID_INPUT` on over-long or unknown fields.
|
||
- `DELETE /api/v1/sessions/:id/intent` -> `{ deleted: boolean }` forgets the
|
||
case's profile entirely.
|
||
- `POST /api/v1/sessions/:id/readmymind` predicts the user's next prompt:
|
||
a one-shot model call over the intent profile plus live session signals
|
||
(pending approval dialog, transcript tail, git state, run-summary events,
|
||
sibling sessions). Body is optional; the rethink flow passes
|
||
`{ steer?, rejected? }` (strict schema: `steer` <= 2000 chars, `rejected`
|
||
up to 10 strings <= 1000 chars). Answers
|
||
`{ suggestions: { prompt, why, kind }[], durationMs }` with 1-3 suggestions
|
||
(`kind`: `continue` | `verify` | `redirect`; prompts are single-line).
|
||
Claude-mode sessions only (`400 INVALID_INPUT` otherwise); one prediction in
|
||
flight per session (`409 CONFLICT`); predictor failures answer
|
||
`502 OPERATION_FAILED`. Takes 5-90 s and costs real tokens. Suggestions are
|
||
only ever returned, never sent: submitting one is the caller's explicit act.
|
||
|
||
All four enforce session ownership in multi-user mode; a foreign session id
|
||
answers `404 NOT_FOUND` (no existence leak), and profiles of two owners of the
|
||
same directory are distinct by construction.
|
||
|
||
## Voice dictation
|
||
|
||
Browser dictation transcribed through this server's Claude Code login, i.e. the
|
||
same speech-to-text service the CLI's own `/voice` mode uses. Gated on the synced
|
||
`claudeVoiceEnabled` setting (default OFF). Design:
|
||
[`claude-voice-plan.md`](claude-voice-plan.md).
|
||
|
||
- `GET /api/v1/voice/status` -> `{ available, reason?, subscriptionType?,
|
||
expiresAt? }`. `reason` is `disabled` (setting off), `no-credentials` (nobody
|
||
signed in to Claude Code on the server), `expired` (the access token elapsed;
|
||
running any Claude session refreshes it) or `malformed`. The OAuth token
|
||
itself is never returned by this or any other endpoint.
|
||
- `GET /ws/voice/stream?language=&keyterms=` (WebSocket, not under `/api`)
|
||
relays one dictation. Client sends binary frames of signed 16-bit
|
||
little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames
|
||
`{"t":"finalize"}` (ask for the final transcript) and `{"t":"stop"}`. Server
|
||
sends `{"t":"ready"}`, `{"t":"transcript","text","final"}` (each frame is the
|
||
WHOLE running transcript, not a delta), `{"t":"error","message"}` and
|
||
`{"t":"closed"}`. Close codes: `4003` disallowed Host/Origin, `4004`
|
||
unavailable (reason in the close reason), `4008` too many concurrent streams.
|
||
Streams are capped in count and length (`src/config/voice.ts`).
|
||
|
||
## Authentication
|
||
|
||
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
|
||
`codeman_session` cookie. When enabled, unauthenticated requests get
|
||
`401 UNAUTHORIZED`; rate-limited requests get `429 RATE_LIMITED`. See
|
||
[`security-architecture.md`](security-architecture.md).
|
||
|
||
## SSE event channel
|
||
|
||
`GET /api/events` is a Server-Sent Events stream (`text/event-stream`); each
|
||
message is `event: <name>` + `data: <json>`. The event-name registry
|
||
(`src/web/sse-events.ts`, mirrored in `src/web/public/constants.js`) is part of
|
||
the stable contract — event names are not renamed without a major bump. An
|
||
optional `?sessions=<id,...>` filter suppresses only the high-volume terminal
|
||
stream; lifecycle/metadata events are delivered to all clients regardless.
|
||
|
||
### `sse:heartbeat` (liveness)
|
||
|
||
Every 15s the server writes a `sse:heartbeat` frame to every connected client:
|
||
|
||
```
|
||
event: sse:heartbeat
|
||
data: {"t":1755100000000}
|
||
```
|
||
|
||
`t` is the server's epoch-ms timestamp at write time. The frame carries no
|
||
application state and can be ignored for correctness. It exists so a client can
|
||
tell a live stream from a dead one: an `EventSource` whose connection has been
|
||
idle-closed by a proxy (or that resumed from sleep on a stale socket) keeps
|
||
delivering nothing without ever firing `onerror`. Clients that care should treat
|
||
silence longer than about three intervals as a dead stream and reconnect, which
|
||
is what the bundled frontend does.
|
||
|
||
This replaced a `:keepalive` SSE **comment**, which served the same
|
||
proxy-flushing purpose but is invisible to `EventSource` by spec and so could
|
||
never be observed by a client. Consumers written against the old behavior are
|
||
unaffected: `EventSource` dispatches only events that have a registered
|
||
listener, so an unknown event name is dropped.
|
||
|
||
## Consuming from JavaScript
|
||
|
||
The bundled frontend reads responses through `_apiJson()`
|
||
(`src/web/public/api-client.js`), which unwraps `{success:true,data}` → `data` and
|
||
returns `null` on a non-2xx / `{success:false}` response. External clients should
|
||
do the same: check the HTTP status (or `body.success`), then read `body.data`.
|