chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-09 01:18:03 +02:00
parent fa18eeef35
commit d26f26fe34
32 changed files with 7075 additions and 54 deletions
+23
View File
@@ -1,5 +1,28 @@
# aicodeman # aicodeman
## 1.13.0
### Minor Changes
- Agent wait primitives, the Codeman agent skill, a fix for hooks dying silently on HTTPS installs, and the tab-strip UX improvements from the previous batch.
**Agent wait primitives (new API surface, the reason this is a minor).** Three bounded long-polls let an agent driving Codeman from a shell block instead of poll:
- `GET /api/v1/sessions/:id/wait` blocks until a lifecycle signal fires (`until=stop,idle,working,blocked,exit`, `fresh=1` to require a new transition).
- `GET /api/v1/sessions/:id/wait-output` blocks until a literal substring appears in the session's output (`match=`, `nocase=`, `from=now|buffer`; never regex, by design).
- `wait`/`waitTimeout` on `POST /api/v1/sessions/:id/input` (send-and-wait) registers the waiter before typing, closing the race where a separate wait reports the previous turn's idle state as this turn's answer.
Shared semantics: a timeout is HTTP 200 with `wait.timedOut: true` (callers loop over short waits; tunnels cut idle connections), timeouts are clamped to [1s, 600s] and echoed back as `wait.timeoutMs`, all three nest the result under `data.wait`, and `status`/`limitPaused` ride along. `stop`/`blocked` exist for `claude` mode only: requesting them explicitly elsewhere is a 400, the default set silently narrows and echoes what it waited on. Capacity caps (16 waiters per session, 128 process-wide) answer 409/429, waiter slots release on client hang-up, and shutdown resolves parked waiters instead of stranding them. Bounds are operator-tunable via `CODEMAN_WAIT_*` env vars.
Reliability details that came out of three verification rounds: a worker that dies inside its tmux pane is now detected at the mux layer (pane-death probe, ~750ms cache, a 3s watcher for waits already parked), so a corpse answers `exit` instead of `idle` and send-and-wait rolls back its dedup seq when the write went nowhere; output matching normalizes charset-designation escapes (a stock bash prompt's `ESC ( B` no longer breaks `match=tnode:`) and holds back partial escapes at chunk boundaries, so matches straddling PTY chunks are found.
**Codeman agent skill (`skills/codeman`).** A packaged skill that teaches an agent running inside a Codeman session to drive the API safely: guard preamble (refuses outside `CODEMAN_MUX=1`, resolves credentials from the data dir `.env` or the install's service definition), self-protection (`is_self` prefix check in both directions), readiness for claude workers (composer-first, trust dialog as bounded fallback), send-and-wait loops that cannot report a never-submitted prompt as success, marker-synchronized shell flows, fan-out patterns, and cleanup discipline. Ships in the npm package via the `files` entry.
**Hooks were dying silently on every HTTPS install (bug fix).** The generated hook curls lacked `-k`, so on `--https` installs (self-signed cert) every hook event (`stop`, `permission_prompt`, `elicitation_dialog`, `idle_prompt`, `teammate_idle`, `task_completed`) failed TLS verification and the failure was swallowed, taking respawn's definitive idle signals with it. Hooks are now generated with `curl -sk`, and a staleness detector regenerates the on-disk hook config of already-created cases the next time a session starts in them. Relatedly, `CODEMAN_API_URL` is no longer exported with a guessed `http://localhost:3000` fallback (wrong scheme on HTTPS installs); it is omitted unless the server has stamped the real URL, so in-session guards fail closed.
**Tab strip (from the previous batch, reported by christianhaberl):** action icons (kill/pop-out) now appear on the active tab only, middle-click closes a tab, tab hover uses a fixed width with a sliding title instead of resizing the strip, and the pop-out button is opt-in (default off).
**Docs.** `docs/api-reference.md` gained the full long-polling contract (signals by mode, readiness, what the matcher sees, response discriminators); `docs/extending-codeman.md` and the README carry verified copy-paste orchestration recipes; `docs/architecture-invariants.md` records the load-bearing ordering, liveness, and edge-triggered-signal invariants. Net +163 tests (4300 passing in the CI sweep).
## 1.12.2 ## 1.12.2
### Patch Changes ### Patch Changes
+4 -2
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.12.2 (must match `package.json`) **Version**: 1.13.0 (must match `package.json`)
## Project Overview ## Project Overview
@@ -180,6 +180,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) **Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Agent wait primitives**: bounded long-polls so an agent driving Codeman from a shell can block instead of poll: `GET /api/sessions/:id/wait` (lifecycle signal), `GET /api/sessions/:id/wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST /api/sessions/:id/input`. Registry in `session-wait-registry.ts` (pure, no `Session` reference), bounds in `config/agent-wait.ts`. ⚠️ **A timeout is a 200** (`wait.timedOut`), never an error, so callers loop over short waits. ⚠️ `stop`/`blocked` come from Claude Code hooks and therefore fire for **`claude` mode ONLY** (`shell` installs none either); asking for one explicitly on another mode is a 400, the default set silently drops them. ⚠️ Send-and-wait registers the waiter BEFORE the write (a separate POST-then-wait races and reports the PREVIOUS turn), and both teardown paths must `notifySignal('exit')` BEFORE `cancelAll()`. ⚠️ Client-hangup abort listens on **`reply.raw`** guarded by `writableFinished`: on `req.raw`, `close` fires when the request BODY ends, which on a POST killed every send-and-wait instantly and no `app.inject()` test could see it. ⚠️ Worker liveness cannot come from `session.pid` — for a tmux session that is the local attach client, which outlives a worker dying inside its pane — so it is probed at the mux layer (`isPaneDead`, ~750 ms cache) on blocking waits only, never on the input hot path. ⚠️ Signals are edge-triggered with no history: one that fires with no waiter registered is unobservable afterwards, so gather fan-outs with send-and-wait or latched `wait-output` markers, never fire-and-forget-then-sequential-signal-waits. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
@@ -292,7 +294,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### API Routes ### API Routes
~199 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. ~200 handlers across 21 route files in `src/web/routes/`: system (45), sessions (34), cases (27), files (16), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
+66 -12
View File
@@ -680,15 +680,21 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
### Rules of the road (read before you POST) ### Rules of the road (read before you POST)
1. **Single-line input only.** Programmatic input is sent as literal text **+ Enter** in one shot. Multi-line strings break the agent TUI (Ink) — send one line, or split into multiple calls. 1. **Single-line input, ending in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs the joined command `echo Aecho B`: send one line per call.
2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt. 2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt.
3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). 3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). ⚠️ A `401` replies with the bare string `Unauthorized`, **not** the JSON envelope, so piping it into `jq` throws a parse error instead of showing the failure: check the status before parsing.
4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`). 4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`).
5. **`/api/v1/*`** is a stable alias of `/api/*`. 5. **`/api/v1/*`** is a stable alias of `/api/*`.
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
### Recipes ### Recipes
```bash ```bash
# CODEMAN_API_URL is auto-set inside every Codeman session, correct scheme included.
# The fallback below fits a stock install; on a --https install set the https:// URL
# yourself and add -k to each curl (self-signed cert).
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}" API="${CODEMAN_API_URL:-http://127.0.0.1:3000}"
# (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set) # (add -u admin:"$CODEMAN_PASSWORD" to each call if a password is set)
@@ -700,18 +706,63 @@ curl -s -X POST "$API/api/quick-start" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq -d '{"caseName":"refactor-auth","mode":"claude","effort":"high"}' | jq
# 2b. Wait until that worker is actually READY (see rule 8): composer marker first,
# first-run trust dialog only as the fallback. (Probing trust first and sending
# a blind Enter misfires on re-runs: the dialog text stays in the buffer forever,
# so the probe matches stale text and the Enter lands in a ready composer.)
# Match single tokens: TUI text can reach the matcher without its spaces.
until [ "$(curl -s "$API/api/sessions/$SID" | jq '.data.pid')" != null ]; do sleep 1; done
R=$(curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=bypass' \
--data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$(curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=trust' \
--data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
curl -s -X POST "$API/api/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true}' # accept the first-run trust dialog
curl -sG "$API/api/sessions/$SID/wait-output" --data-urlencode 'match=bypass' \
--data-urlencode 'from=buffer' --data-urlencode 'timeout=45000' >/dev/null
fi
# 3. Send a prompt into a session (exactly-once: clientId + seq) # 3. Send a prompt into a session (exactly-once: clientId + seq)
curl -s -X POST "$API/api/sessions/$SID/input" \ curl -s -X POST "$API/api/sessions/$SID/input" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"input":"Run the test suite and summarize failures","useMux":true,"clientId":"agent-1","seq":1}' -d '{"input":"Run the test suite and summarize failures\r","useMux":true,"clientId":"agent-1","seq":1}'
# 4. Read the terminal back # 4. Send a prompt and BLOCK until that turn is done (registers the wait before
curl -s "$API/api/sessions/$SID/output" | jq -r '.data // .' # writing, so it can't answer with the previous turn's idle state)
curl -s -X POST "$API/api/sessions/$SID/input" \
-H 'Content-Type: application/json' \
-d '{"input":"Run the test suite and summarize failures\r","useMux":true,
"clientId":"agent-1","seq":2,"wait":"stop,exit","waitTimeout":60000}' \
| jq '.data.wait' # -> {"signal":"stop","timedOut":false,"waitedMs":41230,...}
# (`stop` is the definitive end-of-turn hook. Adding `idle` makes it resolve on a
# spinner pause too, and on anything that redraws a ❯ prompt — like a dialog.)
# 5. Stream live events (session output, agent activity, status) # 4b. Timed out? That's a 200, not a failure. Loop over short waits.
curl -s "$API/api/sessions/$SID/wait?until=stop,exit&timeout=60000" | jq '.data.wait'
# 4c. Or wait for a marker in the output (works for shell sessions too).
# ⚠️ Unique per call (tmux repaints replay old screen text), and SPLIT so the
# typed line never contains it: your own keystrokes echo into the output
# stream, so an unsplit marker matches before the command has run. from=buffer
# catches a marker that printed before the wait landed.
N=$RANDOM
curl -s -X POST "$API/api/sessions/$SID/input" -H 'Content-Type: application/json' \
-d "{\"input\":\"M=DONE; npm test; echo \${M}_$N rc=\$?\r\",\"useMux\":true}"
curl -sG "$API/api/sessions/$SID/wait-output" \
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=60000' | jq '.data.wait'
# 5. Read the terminal back. ⚠️ Use terminal?tail=, NOT /output: the latter's
# textOutput is empty for every tmux-backed (i.e. every interactive) session.
# tail counts BYTES, and what comes back is terminal data, ANSI included.
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
# 6. Stream live events (session output, agent activity, status)
curl -sN "$API/api/events" # Server-Sent Events curl -sN "$API/api/events" # Server-Sent Events
# 6. Schedule recurring work (cron-style job) # 7. Schedule recurring work (cron-style job)
curl -s -X POST "$API/api/cron/jobs" \ curl -s -X POST "$API/api/cron/jobs" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj", -d '{"name":"nightly-deps","agentType":"claude","workingDir":"/home/me/proj",
@@ -719,11 +770,11 @@ curl -s -X POST "$API/api/cron/jobs" \
"inputMode":"typed","scheduleType":"daily","dailyTime":"03:00", "inputMode":"typed","scheduleType":"daily","dailyTime":"03:00",
"enabled":true,"concurrencyPolicy":"warn_only"}' | jq "enabled":true,"concurrencyPolicy":"warn_only"}' | jq
# 7. Inspect background sub-agents and their transcripts # 8. Inspect background sub-agents and their transcripts
curl -s "$API/api/subagents" | jq '.data // .' curl -s "$API/api/subagents" | jq '.data // .'
curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .' curl -s "$API/api/subagents/$AID/transcript" | jq -r '.data // .'
# 8. Whole-system snapshot (sessions, settings, respawn, stats) # 9. Whole-system snapshot (sessions, settings, respawn, stats)
curl -s "$API/api/status" | jq curl -s "$API/api/status" | jq
``` ```
@@ -749,7 +800,7 @@ Codeman registers Claude Code hooks that `POST /api/hook-event` (`permission_pro
## API ## API
REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset: REST over Fastify — **~200 handlers across 21 route modules**, plus an SSE stream and a WebSocket terminal channel. All responses use the `ApiResponse<T>` envelope (`{success, data}` / `{success, error, errorCode}`); `/api/v1/*` is a stable alias. A representative subset:
### Sessions ### Sessions
@@ -757,8 +808,11 @@ REST over Fastify — **~190 handlers across 20 route modules**, plus an SSE str
| -------- | -------------------------- | ---------------------------------------------------------------------------------- | | -------- | -------------------------- | ---------------------------------------------------------------------------------- |
| `GET` | `/api/sessions` | List all | | `GET` | `/api/sessions` | List all |
| `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) | | `POST` | `/api/quick-start` | Create case + start session (`{caseName?, mode?, effort?, envOverrides?}`) |
| `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?}` — `clientId`+`seq` = exactly-once) | | `POST` | `/api/sessions/:id/input` | Send input (`{input, useMux?, clientId?, seq?, wait?, waitTimeout?}`: `clientId`+`seq` = exactly-once; `wait` blocks until the turn ends) |
| `GET` | `/api/sessions/:id/output` | Read terminal output | | `GET` | `/api/sessions/:id/terminal` | Read terminal output (`?tail=<bytes>`, `?full=1`); the read path for interactive sessions |
| `GET` | `/api/sessions/:id/output` | Parsed one-shot output (`textOutput` is empty for tmux-backed sessions) |
| `GET` | `/api/sessions/:id/wait` | Block until a signal fires (`?until=stop,idle,exit&timeout=&fresh=`); a timeout is a `200` |
| `GET` | `/api/sessions/:id/wait-output` | Block until a literal string appears (`?match=&nocase=&from=now\|buffer&timeout=`) |
| `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` | | `GET` | `/api/sessions/unified` | Unified live + history list (Session Manager) — `?q=&limit=` |
| `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) | | `POST` | `/api/sessions/:id/pin` | Pin/unpin in the Session Manager (`{pinned}`) |
| `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) | | `PUT` | `/api/session-order` | Sync tab order across devices (`{order: [ids]}`) |
+662
View File
@@ -0,0 +1,662 @@
# Agent Control Plan: skill packaging + wait primitives
**Status**: steps 1 to 5 IMPLEMENTED and multi-round verified, uncommitted as of 2026-08-08.
Step 6 (CLI install command + per-case injection + `agentSkillEnabled`) is not built.
See [§7 Build log](#7-build-log-what-actually-happened) for what shipped, what each
verification round found, and what is still open.
**Date**: 2026-08-08
**Scope**: Part 1 (agent skill) and Part 2 (wait primitives) were specified and built.
Parts 3 to 5 are captured so they are not lost, but remain deliberately deferred.
---
## 0. Where this came from: what herdr does
[herdr](https://github.com/herdrdev/herdr) (Rust, Apache-2.0, ~25.8k stars) is a terminal
multiplexer built around AI coding agents. Relevant findings from the research pass:
| Capability | How herdr does it |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Agent state | Four states (`idle`, `working`, `blocked`, `done`) that roll up pane to tab to workspace in a sidebar |
| Detection | Lifecycle hooks where the agent supports them (it names Pi and MastraCode), otherwise TOML manifests matched against a live bottom-buffer snapshot. Bundled manifests plus remote updates from herdr.dev, local overrides win |
| Control API | Newline-delimited JSON over a Unix socket (`~/.config/herdr/sessions/<name>/herdr.sock`), `{"id":"req_1","method":"pane.split","params":{}}`, dot-notation methods, plus long-lived event subscriptions |
| Discoverability | `herdr api schema` prints a machine-readable schema |
| Agent skill | `npx skills add herdrdev/herdr --skill herdr -g`, a SKILL.md wrapping the CLI, guarded by `test "${HERDR_ENV:-}" = 1` so an agent outside a herdr pane refuses to act |
| Persistence | Background server, detach with `ctrl+b q`, snapshot restore of workspaces/tabs/panes/cwd/layout, experimental screen-history replay, agent resume via native session ids, live PTY handoff across server replacement |
| Plugins | `herdr-plugin.toml` manifest, actions, event hooks, plugin panes, link handlers, GitHub-topic marketplace index |
The commands the skill teaches the agent:
| Group | Commands |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| workspace | `workspace list`, `workspace create` |
| tab | `tab list --workspace <id>`, `tab create` |
| pane | `pane current`, `pane list`, `pane layout`, `pane split --current --direction right --cwd <path> --no-focus`, `pane run <id> "<cmd>"`, `pane wait-output <id> --match/--regex <p> --timeout <ms>`, `pane read <id> --source visible\|recent\|detection` |
| agent | `agent list`, `agent start <name> --kind <type> --pane <id>`, `agent prompt <name> "<text>" --wait --timeout <ms>`, `agent wait <name> --until <state> --timeout <ms>`, `agent send-keys`, `agent get`, `agent read` |
### The honest comparison
herdr and Codeman are not the same product. herdr is a local, keyboard-first multiplexer with
no server, no web UI, and no autonomy layer. Codeman is a server with a browser and mobile UI,
remote and Docker cases, respawn, Ralph, cron, and the orchestrator, none of which herdr has.
What herdr genuinely does better is being **callable by the agent running inside it**. For
Codeman that is a packaging problem plus one missing primitive, not an architecture problem.
---
## 1. Gap analysis
| herdr capability | Codeman equivalent today | Gap |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `pane split` + `agent start` | `POST /api/quick-start`, `POST /api/sessions` | none, already there |
| `agent prompt` | `POST /api/sessions/:id/input` with `clientId`+`seq` exactly-once | no `--wait` |
| `pane read` | `GET /api/sessions/:id/output`, `GET /api/sessions/:id/terminal?full=1` | none |
| `agent list` / `agent get` | `GET /api/sessions`, `GET /api/sessions/unified`, `GET /api/status` | none |
| `agent wait --until <state>` | SSE only (`/api/events`) | **missing**, and SSE is impractical from a shell tool |
| `pane wait-output --match` | nothing | **missing** |
| Skill file | README section "Driving Codeman from an Agent" | **not packaged**, an agent will never find it |
| Env guard `HERDR_ENV=1` | `CODEMAN_MUX=1`, `CODEMAN_API_URL`, `CODEMAN_SESSION_ID` already exported at spawn | none, the guard variables exist |
| `blocked` state | hook events (`permission_prompt`, `elicitation_dialog`) plus CSS classes plus the phone overview NEEDS YOU section | not in the wire contract (`SessionStatus = 'idle' \| 'busy' \| 'stopped' \| 'error'`) |
| `api schema` | hand-written `docs/api-reference.md` | no machine-readable schema |
| Detection manifests | hardcoded in `usage-limit-patterns.ts`, `respawn-*-patterns`, `regex-patterns.ts` | patterns are code, not data |
| Plugin runtime | deliberately refused, see `docs/extending-codeman.md` | not a gap, a decision |
| Session handoff on restart | tmux owns the PTYs, so they already survive a Codeman restart | not a gap, solved by architecture |
**Conclusion**: roughly 90% of the capability surface already exists. Parts 1 and 2 below close
the two real gaps.
---
## 2. Part 1: the Codeman agent skill
### 2.1 Goal
An agent running inside a Codeman session can discover and correctly drive Codeman without the
user pasting API docs into the prompt, and without inventing dangerous calls.
### 2.2 Layout and distribution
The `npx skills` CLI (vercel-labs/skills) clones a GitHub repo and looks for
`skills/<name>/SKILL.md`. Claude Code natively discovers `.claude/skills/<name>/SKILL.md` in a
project and `~/.claude/skills/` globally. Both are satisfied with one source of truth plus a
symlink, which is the pattern this repo already uses for `remotion-best-practices`.
```
skills/
codeman/
SKILL.md <- single source of truth
reference/
endpoints.md <- full endpoint tables, loaded on demand
recipes.md <- worked multi-session orchestration examples
.claude/skills/codeman -> ../../skills/codeman (symlink, dogfooding in this repo)
```
Adding a `skills/` directory to the repo root costs one entry in the GitHub listing. CLAUDE.md
keeps the root short on purpose, so this needs a conscious sign-off; the alternative is
`docs/skills/codeman/` with a `--skill` path argument, which breaks the one-liner install.
**Recommendation**: accept `skills/` at the root, because the install one-liner is the whole
point of shipping a skill.
Install paths, in order of how a user gets it:
1. `npx skills add Ark0N/Codeman --skill codeman -g` (global, any agent, matches the herdr flow).
2. `codeman skill install [--global | --case <name>]`, a new CLI subcommand writing the same
file. This is the path for users who installed via npm and never cloned the repo.
3. **Automatic per-case injection**, modeled exactly on `applyStatusLineConfig(casePath, enabled)`
in `hooks-config.ts`: write `<case>/.claude/skills/codeman/SKILL.md` at case creation,
gated on a new setting. Codeman already writes `<case>/.claude/settings.local.json` hooks
through `writeHooksConfig()`, so this is the same mechanism with the same lifecycle.
Setting name: `agentSkillEnabled`. Synced (not per-device), since it changes on-disk case
content rather than display. Default: **ON after the dogfooding phase, OFF in the first
release**. Rationale for starting OFF: Claude Code loads every skill's name and description
into context on every turn, so an always-on skill has a small permanent token cost, and we
should measure that we are buying something with it first.
### 2.3 SKILL.md content
Frontmatter, per the skills convention (`name` + `description` required):
```yaml
---
name: codeman
description: >-
Control Codeman, the session manager this agent is running inside: list sessions,
start worker sessions, send prompts, read terminal output, and wait for other agents
to finish. Only usable when CODEMAN_MUX=1.
---
```
Body sections, in order:
**1. Guard (first thing, non-negotiable).**
```bash
test "${CODEMAN_MUX:-}" = 1 || { echo "not inside a Codeman session"; exit 1; }
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set, refusing to guess}"
SELF="${CODEMAN_SESSION_ID:-}"
```
If `CODEMAN_MUX` is not `1`, the agent must stop and say it is not running inside a
Codeman-managed session. Same shape as herdr's `HERDR_ENV` guard, and the variables are
already exported by `tmux-manager.buildEnvExports()`. No fallback URL when
`CODEMAN_API_URL` is unset: any guess is the wrong scheme on an HTTPS install (prod is
HTTPS with a self-signed cert, hence `curl -sk` throughout), and a server the agent
cannot identify is not one it should be driving.
**2. Rules of the road.** Lifted and tightened from README lines 666 to 745:
- Single-line input only. Multi-line breaks the agent TUI (Ink).
- Always send `clientId` + a monotonic `seq` on `POST .../input` so a retry cannot double-deliver.
- Envelope is `{success, data}`; a few legacy GETs are bare, so read `body.data ?? body`.
- Add `-u admin:"$CODEMAN_PASSWORD"` when a password is set. Prod is HTTPS, so `curl -sk`.
- Prefer `/api/v1/*`, the stable alias.
**3. Safety rules (the section that does not exist anywhere today).**
- Never act on `$CODEMAN_SESSION_ID`. That is you.
- Only `DELETE` sessions **you created in this conversation**, by exact id. Keep the list.
- Never bulk-delete, never loop a `DELETE` over `/api/sessions`. There is no undo.
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. Use the API.
- Creating a session consumes a slot against the 50-session cap. Clean up what you start.
**4. Recipes**, each one a single copy-pasteable curl:
| Task | Call |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| list sessions | `GET /api/v1/sessions` |
| find yourself | match ids by PREFIX of `$CODEMAN_SESSION_ID` (Docker cases truncate it to 8 chars, so an equality check never fires there) |
| start a worker | `POST /api/v1/quick-start {caseName, mode, effort}` |
| send a prompt | `POST /api/v1/sessions/:id/input {input:"…\r", useMux:true, clientId, seq}` (the trailing `\r` is what sends Enter; without it the text sits on the prompt unsubmitted) |
| send prompt and wait | `POST /api/v1/sessions/:id/input {input:"…\r", wait:"stop", waitTimeout:600000}` (Part 2) |
| wait for a worker | `GET /api/v1/sessions/:id/wait?until=stop,blocked&timeout=300000` (Part 2) |
| wait for a marker | `GET /api/v1/sessions/:id/wait-output?match=DONE_<random>&timeout=120000` (Part 2; unique per call, per §3.3's repaint rule) |
| read output | `GET /api/v1/sessions/:id/output` |
| read full scrollback | `GET /api/v1/sessions/:id/terminal?full=1` |
| watch sub-agents | `GET /api/v1/subagents` |
| schedule work | `POST /api/v1/cron/jobs` |
| clean up | `DELETE /api/v1/sessions/:id` |
**5. Pointer to `reference/endpoints.md`** for anything not in the table, so the always-loaded
part of the skill stays small.
### 2.4 An ergonomics guard worth adding server-side
The skill will tell the agent not to act on itself, but a confused agent can still try. Propose:
the skill sends `X-Codeman-Caller-Session: $CODEMAN_SESSION_ID` on every request, and the server
refuses destructive operations (`DELETE /api/sessions/:id`, kill, respawn stop) when that header
equals the target id, with a clear error.
This is a **footgun guard, not a security control**: any caller can omit the header. Document it
as such so nobody mistakes it for a boundary. It costs about 10 lines in `route-helpers.ts`.
### 2.5 Verification
Per the always-end-to-end-test rule, "the skill exists" is not done. Done is:
1. Symlink it into `.claude/skills/`, start a real throwaway Codeman session, and ask that agent
to "start a worker session that runs the test suite and tell me when it finishes".
2. Confirm from the outside that exactly one new session appeared, got the prompt, and that the
lead agent waited rather than polling in a busy loop.
3. Confirm the guard: run the same prompt in a shell with `CODEMAN_MUX` unset and confirm refusal.
4. Confirm cleanup: the worker session is deleted by exact id and no other session was touched.
Never run this against `w1`/`w2`/`w3`.
### 2.6 Files touched
- `skills/codeman/SKILL.md` (new), `skills/codeman/reference/*.md` (new)
- `.claude/skills/codeman` symlink (new)
- `src/cli.ts` (new `skill install` subcommand)
- `src/hooks-config.ts` (new `applyAgentSkill(casePath, enabled)`, mirroring `applyStatusLineConfig`)
- `src/web/schemas.ts` (`agentSkillEnabled` in `SettingsUpdateSchema`, which is `.strict()`)
- `src/web/routes/system-routes.ts` (settings PUT must resolve the flag from `merged`, never
from the raw body, per the partial-PUT invariant)
- `src/web/public/settings-ui.js` + `index.html` (checkbox)
- `package.json` `files` array, so `skills/` ships to npm
- README pointer, `docs/extending-codeman.md` seam 3 pointer
---
## 3. Part 2: wait primitives
### 3.1 Goal
Make Codeman orchestratable from a shell tool. Today the only "tell me when" channel is SSE,
which a curl-driven agent cannot practically consume: it would have to hold a streaming
connection and parse events inline. herdr solves this with blocking CLI calls. Codeman should
solve it with bounded long-poll endpoints.
All three additions are **additive**, so the versioning policy stays intact (new endpoints and
new optional fields are non-breaking).
### 3.2 The signal model
A waiter resolves on the first of a set of signals. Sources that already exist:
| Signal | Source today |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `idle` | `Session` emits `idle` (session.ts ~1775 for Claude, ~2101 for shell), wired at `session-listener-wiring.ts:402` |
| `working` | `Session` emits `working` (session.ts ~1788), wired at `session-listener-wiring.ts:401` |
| `stop` | `POST /api/hook-event` with `event: 'stop'`, the definitive "Claude finished responding" signal already used by `controller.signalStopHook()` |
| `blocked` | `POST /api/hook-event` with `permission_prompt` or `elicitation_dialog` |
| `exit` | `Session` emits `exit` |
`stop` is the highest-quality signal for "the turn is over" and should be the documented default
for orchestration. `idle` is heuristic: output stabilization plus prompt detection, and it can
flap mid-turn when a spinner pauses. External CLI modes (`isExternalCliMode()`) have no stop
hook at all, so for opencode/codex/gemini/antigravity only `idle`, `working` and `exit` are
available. **The skill and the docs must say which signals exist per mode**, otherwise an agent
waits forever on `stop` in a codex session.
### 3.3 Endpoint specs
#### A. `GET /api/sessions/:id/wait`
| Param | Type | Default | Notes |
| --------- | ---------------------------------------------- | ---------------- | ------------------------------------------------------------ |
| `until` | comma list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on first match |
| `timeout` | ms | 60000 | clamped to `MAX_WAIT_MS` (600000) |
| `fresh` | `0`/`1` | `0` | `1` requires a _transition_, ignoring the state at call time |
Response (always 200 unless the session is missing or a cap is hit):
```json
{
"success": true,
"data": {
"signal": "stop",
"timedOut": false,
"immediate": false,
"ended": false,
"waitedMs": 8421,
"status": "idle",
"sessionId": "...",
"until": ["stop", "idle", "exit"],
"limitPaused": false
}
}
```
`until` is echoed back because the server may narrow it: `stop`/`blocked` are dropped
from the DEFAULT set for external CLI modes (asking for them EXPLICITLY is a 400
instead, since omitting `until` must never 400). `limitPaused` tells a caller that a
timeout was expected rather than a stall worth retrying hard.
**A timeout is not an error.** `{"timedOut": true, "signal": null}` with HTTP 200, so a caller
can loop without treating every poll boundary as a failure. Errors are reserved for
`NOT_FOUND` (unknown or not-owned session) and `SESSION_BUSY` (waiter cap exceeded).
`immediate: true` means the session was already in the requested state and `fresh` was not set.
#### B. `GET /api/sessions/:id/wait-output`
| Param | Type | Default | Notes |
| --------- | ------------------------------ | -------- | --------------------------------------------------------- |
| `match` | literal string, 1 to 200 chars | required | substring match against ANSI-stripped output |
| `nocase` | `0`/`1` | `0` | case-insensitive compare |
| `from` | `now` \| `buffer` | `now` | `buffer` scans the existing text buffer first, then waits |
| `timeout` | ms | 60000 | clamped to `MAX_WAIT_MS` |
Response: `{ matched: true, timedOut: false, snippet: "...", waitedMs }`.
**No regex in v1, deliberately.** `search-service.ts` already avoids regex specifically so there
is no ReDoS surface, and this endpoint would be even more exposed since the pattern is attacker
supplied and the input is a live stream. herdr can offer `--regex` because Rust's regex crate is
linear-time with no backtracking; JS `RegExp` is not. If regex is wanted later, the honest
options are a length-capped subset compiled once with a match budget, or `re2`. Note it and move on.
Implementation detail that will bite if missed: a match can straddle two PTY chunks. Keep a
carry buffer of `match.length - 1` bytes from the previous chunk and test `carry + chunk`.
⚠️ **`from=now` does not mean "printed after you asked".** tmux repaints the visible
screen on attach, resize, or any TUI redraw, and a repaint arrives as ordinary `terminal`
data. Observed live: a marker echoed a minute earlier matched instantly on a fresh
`from=now` wait. This is inherent to a terminal multiplexer, not fixable in the registry,
so the contract is: **use a marker unique per call** (`echo DONE_$RANDOM`), never a
generic one like `BUILD OK`. The skill's recipes must show that.
The returned snippet is whitespace-collapsed (blank runs to a single newline) for
readability only; matching runs on the raw stripped text. Without it, a real pane's
`\r\n` padding between the prompt and the match fills the whole context window with
nothing, which was the first thing the live test showed.
#### C. `wait` on the existing input endpoint
`POST /api/sessions/:id/input` gains two optional fields:
```json
{ "input": "run the tests\r", "useMux": true, "clientId": "agent-1", "seq": 7, "wait": "stop", "waitTimeout": 600000 }
```
(The trailing `\r` is required on every input body: `sendInput` sends Enter only
when the input contains a carriage return.)
Response gains `"wait": { "signal": "stop", "timedOut": false, "waitedMs": 41230 }`.
This is the important one, because it closes a race the standalone `GET .../wait` cannot: between
"input delivered" and "session flips to working" there is a window where a naive
send-then-wait sees the _pre-existing_ idle state and returns instantly. The combined endpoint
**registers the waiter before writing**, so that window does not exist. This is exactly why herdr
ships `agent prompt --wait` as its own thing.
`wait` accepts `true` (the default signal set) or the same comma grammar as `until`.
Both new fields are `.nullish()`, not `.optional()`: a third-party caller building the
body with `JSON.stringify` keeps an explicit `null` on the wire, and `.optional()`
rejects that with `INVALID_INPUT`. That gotcha has shipped as a real bug twice.
Two behaviors to preserve carefully:
- **`useMux` is fire-and-forget today.** The handler responds without awaiting `writeViaMux`, on
purpose (a tmux child process must not block the HTTP response). With `wait` present the
handler already has to stay open, so it can await delivery, and a `writeViaMux` failure becomes
observable for the first time. The non-wait path must keep its current fire-and-forget shape
byte for byte.
- **Duplicate suppression.** A tagged redelivery (`clientId`+`seq` already applied) returns 200
without writing. With `wait` set it still waits, since the caller's intent is "tell me when
this settles". But it waits with `requireTransition: false`, unlike a fresh delivery: the
original turn may be long over, and requiring a new transition would block a redelivery until
timeout for no reason. Fresh delivery requires a transition, a duplicate answers from the
current state.
- **Capacity rollback.** `shouldApplyInput()` MUTATES (it records the seq), and it runs before
the waiter is registered. If registration then fails on a full pool, the handler must call
`forgetInputSeq` before returning `SESSION_BUSY`, or the caller's retry is rejected as a
duplicate and the input is lost by the very mechanism reliable delivery exists for.
### 3.4 Module design
New file `src/web/session-wait-registry.ts`, with the IO-free core unit-testable in isolation
(same split as `self-update.ts`):
```ts
type WaitSignal = 'idle' | 'working' | 'stop' | 'blocked' | 'exit';
waitForSignal(sessionId, { until: Set<WaitSignal>, timeoutMs, requireTransition }): Promise<WaitResult>
notifySignal(sessionId, signal: WaitSignal): void
waitForOutput(sessionId, { match, nocase, timeoutMs }): Promise<OutputWaitResult>
notifyOutput(sessionId, chunk: string): void
cancelAll(sessionId, reason): void
```
Wiring points, all existing:
- `src/web/session-listener-wiring.ts` around lines 190 and 200 already handles `working` and
`idle` and broadcasts them. Add a `notifySignal()` call next to each broadcast, plus `exit`.
- `src/web/routes/hook-event-routes.ts` already switches on `event` for the respawn controller.
Add `notifySignal(sessionId, 'stop' | 'blocked')` in the same switch.
- Output: `notifyOutput()` rides the ALREADY-attached `terminal` listener in
session-listener-wiring.ts. An earlier draft had the registry hand out attach/detach
callbacks so a listener could be added lazily; that was deleted once it was clear no
second listener is needed at all. The cost is one Map lookup per PTY chunk, which is why
the no-waiter check comes before the ANSI strip.
- Session deletion calls `notifySignal('exit')` then `cancelAll()`, so no promise is left
hanging. Both are required: `_doCleanupSession` detaches the session's listeners BEFORE
`session.stop()`, so on a delete the PTY exit event never reaches the registry, and an
`until=exit` caller would otherwise get a bare `ended` instead of its signal. Found by
live-testing the delete path, not by the unit tests.
Memory-leak discipline, per the 24-hour-session rules: every waiter owns a timer that is cleared
on resolve, the per-session waiter set is deleted when it empties, and the output listener is
removed with it. `test/memory-leak-prevention.test.ts` should grow a case for this.
Caps in a new `src/config/agent-wait.ts` (limits live in `src/config/`, env-overridable):
| Constant | Default | Why |
| ------------------------- | ------- | --------------------------------------- |
| `MAX_WAIT_MS` | 600000 | an unbounded long-poll is a socket leak |
| `DEFAULT_WAIT_MS` | 60000 | short enough to survive most proxies |
| `MAX_WAITERS_PER_SESSION` | 16 | |
| `MAX_WAITERS_TOTAL` | 128 | same reasoning as `MAX_SSE_CLIENTS` |
Exceeding a cap returns `SESSION_BUSY`, not a silent queue.
### 3.5 Transport concerns
Fastify is constructed with defaults in `server.ts:329-331`. `requestTimeout` defaults to 0
(disabled) and `keepAliveTimeout` (72s) applies between requests, not to an in-flight one, so a
10-minute in-process hold is fine. **Verify this on the real instance before relying on it.**
Intermediaries are the actual risk. Prod is reached through `tailscale serve`, and users also run
cloudflared tunnels; both can cut an idle connection. That is why `DEFAULT_WAIT_MS` is 60s and
why the documented pattern is a client-side loop over short waits rather than one 10-minute call.
The skill's recipes must show the loop.
### 3.6 Edge cases to get right
| Case | Behavior |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session already idle, `fresh=0` | return immediately, `immediate: true` |
| Session already idle, `fresh=1` | wait for the next transition into a requested state |
| Session dies mid-wait | resolve with `signal: "exit"` if `exit` was requested, otherwise resolve `timedOut:false, signal:null, ended:true`. Never hang |
| Session deleted mid-wait | same, resolve, do not throw. Verified live: `until=exit` gets `signal:"exit"`, a concurrent `until=blocked` gets `ended:true`, both in ~0ms |
| Shutdown with a wait pending | `cancelEverything()` in `stop()`. Verified live: SIGTERM with a 300s wait in flight exits in 1s |
| External CLI mode | `stop` and `blocked` never fire. Reject `until=stop` for those modes with a clear `INVALID_INPUT` rather than hanging until timeout |
| Multi-user | goes through `findSessionOrFail(ctx, id, req)`, which already enforces ownership |
| Remote / Docker cases | signals originate from the same `Session` object, so no special casing. Docker hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for `stop`/`blocked` to arrive at all; without it, only `idle` works. Document it |
| Respawn `/clear` mid-wait | a respawn cycle emits `idle`. Callers waiting on `stop` are unaffected; callers on `idle` may resolve early. Documented, not fixed |
| Limit pause | if the session is paused on a usage limit, nothing will fire until the reset. The wait times out honestly. Consider surfacing `limitPaused: true` in the response so the caller can back off |
### 3.7 Tests
- `test/session-wait-registry.test.ts` (pure): immediate resolve, transition-required, multi-signal
first-wins, timeout, cap exceeded, cancel on session end, no listener leak after resolve,
chunk-straddling output match, case-insensitive match.
- `test/routes/session-wait-routes.test.ts` (`app.inject()`, no port): all three endpoints against
a `MockSession`, including the 200-with-`timedOut` contract and the ownership 404.
- `test/routes/session-input-wait.test.ts`: the send-and-wait race, plus proof that the non-wait
path is unchanged (still returns before `writeViaMux` settles).
- Live verification on a throwaway session before COM, per the always-end-to-end-test rule.
### 3.8 Files touched
- `src/config/agent-wait.ts` (new)
- `src/web/session-wait-registry.ts` (new)
- `src/web/session-listener-wiring.ts` (notify on idle/working/exit)
- `src/web/routes/hook-event-routes.ts` (notify on stop/blocked)
- `src/web/routes/session-routes.ts` (two new routes, `wait` fields on input)
- `src/web/schemas.ts` (`SessionWaitQuerySchema`, `SessionWaitOutputQuerySchema`, extend
`SessionInputWithLimitSchema`. Note: `.optional()` rejects `null`, so the frontend and any
generated client must send `undefined`, never `null`)
- `docs/api-reference.md`, `docs/extending-codeman.md`, README API table
- `skills/codeman/SKILL.md` recipes (Part 1 depends on this)
---
## 4. Deferred: parts 3 to 5
Not in scope now, kept here so they are not lost.
### Part 3: promote `blocked` to a first-class state
`SessionStatus` is `'idle' | 'busy' | 'stopped' | 'error'`. "Needs you" exists three times over:
hook events, the `tab-alert-action` CSS class, and the phone overview NEEDS YOU section, each
re-deriving it. herdr makes `blocked` a real state that rolls up.
Add `blocked` (and possibly `done`) to `SessionStatus`, set it from the same hook events that
Part 2 uses as wait signals, and clear it on the next `working`/`stop`. Then the tab strip, the
mobile overview, the wait endpoints, and any external agent read one field.
Cost: `SessionStatus` is a widely-consumed union, so every exhaustive `switch` (the codebase has
`assertNever` and `noFallthroughCasesInSwitch`) will need a branch. That is a feature, it makes
the compiler find every site. This is a **minor** bump, not a patch: it widens a public type in
the HTTP contract.
### Part 4: `GET /api/schema`
herdr ships `herdr api schema`. Every Codeman route is already Zod-validated, so
`zod-to-json-schema` over `schemas.ts` gives a self-describing API almost free. Value: third-party
tools and the skill stop drifting from hand-written docs. Open question: whether to emit full
OpenAPI (`@fastify/swagger` would need per-route schema registration, which is a much larger
change) or just dump the Zod schemas keyed by name (cheap, 80% of the value).
### Part 5: detection manifests instead of hardcoded patterns
CLI-specific readiness, blocked and usage-limit patterns live in code across
`usage-limit-patterns.ts`, the respawn pattern helpers and `regex-patterns.ts`. Externalizing the
per-CLI ones into data files would make adding a sixth CLI a data change instead of a code change.
**Do not copy the remote-update part.** herdr auto-fetches manifest updates from herdr.dev.
Codeman auto-pulling behavioral rules from a vendor server contradicts its security posture.
Bundled manifests plus local override only, no network.
### Explicit non-goals
- **Plugin runtime and marketplace.** `docs/extending-codeman.md` already argues this: a plugin
runtime means third-party code inside a process that spawns agents with your credentials, on a
server people expose over a tunnel. The reasoning still holds. If the marketplace _pattern_ is
wanted, apply it to data (web tabs, case templates, cron recipes), never to executable code.
- **Live PTY handoff on restart.** herdr needs it because it owns the terminals. Codeman
delegates to tmux, so PTYs already survive a self-update restart.
- **Socket API.** HTTP plus SSE is the existing, documented, stable contract. A second transport
would double the surface for no capability gain.
---
## 5. Sequencing
| Step | Work | Gate |
| ---- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 ✅ | `src/config/agent-wait.ts` + `session-wait-registry.ts` + unit tests | 48 tests green |
| 2 ✅ | `GET .../wait` + wiring in listener-wiring, hook-event-routes, server teardown | 15 route tests green; live-verified on an isolated `CODEMAN_INSTANCE=waittest` instance (immediate resolve, 400 on a bad signal, 200+`timedOut` on timeout, hook `stop` and `permission_prompt`→`blocked` waking an in-flight wait, delete delivering `exit`, SIGTERM not blocked); full `test:ci` sweep green |
| 3 ✅ | `GET .../wait-output` | 16 route tests green; live-verified on real PTY bytes (`echo MARKER` waking a blocked request in ~1s, `from=buffer` immediate hit, never-seen marker timing out at exactly 2001ms, nocase, `regex` refused with a 400); full `test:ci` sweep green |
| 4 ✅ | `wait` field on `POST .../input`, non-wait path proven unchanged | 16 route tests green; live-verified (no-wait returns in 26ms with the historical bare body; an idle session did NOT satisfy a `wait` request, blocking the full 2001ms, which is the race the endpoint exists to close; the stop hook resolved a send-and-wait at 1510ms and the input was confirmed in the tmux pane; `wait:null` accepted) |
| 5 | `skills/codeman/SKILL.md` + reference files + `.claude/skills` symlink | live dogfood: a real session orchestrates a worker end to end |
| 6 | `codeman skill install` CLI + `applyAgentSkill()` + `agentSkillEnabled` setting | settings partial-PUT test, case-creation test |
| 7 | Docs: api-reference, extending-codeman, README | |
| 8 | COM (minor bump: new endpoints, new setting, new optional fields) | both CI and Release workflows green |
Parts 1 and 2 are independent enough to land separately, but the skill is much less useful
without the wait endpoints, so the wait work goes first.
## 6. Open questions for the owner
1. `skills/` at the repo root, accepted despite the short-root rule? (Recommended yes, the
install one-liner depends on it.)
2. `agentSkillEnabled` default: OFF for the first release then flip, or ON immediately?
3. Auto-inject the skill into every case's `.claude/skills/`, or global install only?
4. Is `X-Codeman-Caller-Session` self-protection worth the 10 lines, given it is a footgun guard
and not a security boundary?
5. Regex support in `wait-output`: confirm literal-only for v1.
---
## 7. Build log: what actually happened
Written at the end of the build so the next person inherits the reasoning, not just the
diff. Process artifacts (per-agent briefs, findings, reports) live in the gitignored
`tmp/agent-wait-review/`; this section is the part worth keeping.
### What shipped
| Piece | Files |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Bounds + clamping | `src/config/agent-wait.ts` (new) |
| Blocking-wait registry | `src/web/session-wait-registry.ts` (new, IO-free, unit-tested) |
| `GET .../wait`, `GET .../wait-output`, `wait`/`waitTimeout` on `POST .../input` | `src/web/routes/session-routes.ts` |
| Signal wiring | `session-listener-wiring.ts` (idle/working/exit + output), `hook-event-routes.ts` (stop/blocked), `server.ts` (teardown, shutdown) |
| Agent skill | `skills/codeman/SKILL.md` + `reference/`, `.claude/skills/codeman` symlink, `package.json` `files` |
| Docs | `api-reference.md`, `extending-codeman.md`, `architecture-invariants.md`, `README.md`, `CLAUDE.md` |
| Tests | `test/session-wait-registry.test.ts`, three `test/routes/session-*wait*.test.ts`, `http-contract.test.ts`, `mock-session.ts` |
### Bugs found in ADJACENT code, not in the new feature
These are the highest-value output of the exercise and none were on the plan:
1. **Every Codeman hook was dead on HTTPS installs.** `hooks-config.ts` built the hook
curl as `curl -s` with no `-k` while the statusline exporter 300 lines below used
`curl -sk` and documented why. Proven with the real hook command: `curl exit=60`
without the flag, success with it, and the failure swallowed by the hook's own
`2>/dev/null || true`. This silently killed `stop`, `permission_prompt`,
`elicitation_dialog`, `idle_prompt`, `teammate_idle` and `task_completed`, taking
respawn's definitive idle signals with them. Fixed, **plus** a staleness detector in
`refreshStaleCodemanHooks` that regenerates the on-disk config of already-created
cases (23 of 26 local cases carried the broken form; fixing the generator alone would
have left every one of them broken).
2. **`buildEnvExports()` exported a wrong-scheme `CODEMAN_API_URL`** (`http://` fallback
on an HTTPS install). Now omitted rather than guessed, so in-session guards fail closed.
3. **Programmatic input is only submitted when it contains `\r`.** `sendInput` sends Enter
only if the payload has a carriage return; without it the text sits in the composer
forever. Bit this build repeatedly before it was diagnosed, and had leaked into the
docs' own examples.
### Design decisions worth not re-litigating
- **A timeout is HTTP 200** with `wait.timedOut`, never a 4xx: callers loop over short
waits because tunnels cut idle connections, and every poll boundary would otherwise be
indistinguishable from failure.
- **Send-and-wait must be one endpoint.** A separate POST-then-wait races: between the
write and the flip to `working`, a wait sees the stale `idle` and reports the PREVIOUS
turn as this one. The waiter is registered before the write.
- **`stop`/`blocked` exist for `claude` mode only.** They come from Claude Code hooks;
`shell` installs none either, so keying off `isExternalCliMode()` was wrong.
- **Literal matching only, never regex.** JS `RegExp` backtracks; herdr can offer
`--regex` because Rust's regex crate is linear-time.
- **Client-hangup abort listens on `reply.raw` guarded by `writableFinished`.** On
`req.raw`, `close` fires when the request BODY ends, which on a POST killed every
send-and-wait instantly, and no `app.inject()` test can see it (inject never emits
`close`).
- **Liveness cannot come from `session.pid`.** For a tmux session that is the local
`tmux attach` client, not the worker: a worker exiting inside its pane leaves
`pane_dead=1` with the client alive, so `pid` never goes null. Liveness is probed at
the mux layer, cached (~750 ms) and only on blocking waits, never on the input hot path.
### Verification rounds
Six agents across three rounds, each verifying the previous round's work rather than its
own. Findings that mattered, in order of severity, were: the dead-pane liveness gap; the
`reply.raw` abort regression; abandoned long-polls leaking waiter slots; a crashed session
reporting `idle`; `shell` accepting `until=stop`; and a documented recipe that reported
success without running its task. Two traps recurred often enough to name:
- **Vacuous passes.** `app.inject()` never emits `close`; a latched `cancelEverything()`
in `afterEach` silently killed the registry for every later test in a file; three test
files sharing one session id against the process-wide registry let one file's leftover
waiter fail another's assertion. Any new wait test needs care on all three.
- **HTTP-only test instances.** Every isolated instance used during the build was plain
HTTP, which is exactly why the HTTPS hook bug survived so long. Test the transport the
user actually runs.
### Resolved at wrap-up (2026-08-08, conclusion pass)
- **R2-A**: the fire-and-forget-then-gather-sequentially pattern was **removed from
the skill** rather than patched. Signals are edge-triggered with no history, so a
`stop` that fires before its waiter registers is unobservable afterwards; a
`fresh=0` gather was rejected because the only `until` set that current state can
satisfy answers `idle` for a prompt that never submitted, resurrecting the exact
false-success failure R2-B had just closed. Flow 3b's pattern B now gathers on
latched `wait-output` markers (`from=buffer`), the same mechanism that makes the
shell flows reliable; the limitation is recorded in
`architecture-invariants#agent-wait-primitives` and `endpoints.md`. The durable
fix, a latched last-signal-per-turn on the server, stays with deferred Part 3.
- Docs F7/F8, F4 and the false-`idle` attribution: `api-reference.md`,
`extending-codeman.md` and `architecture-invariants.md` rewritten to the post-fix
matcher (one normalized stream, chunk-straddling found, snippet as a rendering of
the matched window), the real no-PTY answer (`ended:true`, `aborted:false`,
`delivered:false`), and the startup-idle mechanism (a session parked on the trust
dialog emits no further `idle`; the false success is the startup transition).
- Orchestrate #12, #5/R2-B, #6, and R2-C..R2-E: fire-and-forget's empty `data`
documented; every send-and-wait retry loop now treats `duplicate:true` +
`immediate:true` as "no new turn ran" and reads the terminal before believing it;
claude fan-out is pattern A (backgrounded send-and-waits) or the marker gather;
readiness budgets rebalanced (5 s stage 1, 45 s stage 3) with the virgin-case
floor named; the auth fallback now also reads the supervisor definition
(`codeman-web.service` / launchd plist) and accepts `export`-prefixed `.env`
lines; `pid != null` is documented as startup-only, never liveness.
- Both public readiness recipes (extending-codeman.md, README) are bypass-first with
the trust probe as the bounded fallback; the worked recipe carries `-k` and fails
loudly on an empty SID; the hook `-k`/self-heal fix appears in every
"hooks go missing" list; the multi-word-TUI claim is "unreliable", not "never".
### Still open
- **Release checklist**: `package.json` `files` includes `skills`, which is still
untracked. `git add skills/` must be part of the release commit, or npm publishes
a tarball without the skill (a `files` entry that does not exist is silently
ignored, so nothing fails).
- The 1.13.0 changeset is written under `.changeset/`; consuming it (COM flow),
the release commit, and the deploy remain.
- Deferred with Part 3: the latched last-signal-per-turn. Nice-to-haves from the
reviews: N2 (create the death-watcher inside its `try`) and converting
timeout-shaped test detections into fast assertions.
+341
View File
@@ -46,6 +46,20 @@ payload return `{ "success": true, "data": {} }`.
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the > `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
> `GET /ws/sessions/:id/terminal` WebSocket upgrade. > `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 ## Error codes → HTTP status
The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in
@@ -66,6 +80,333 @@ the HTTP status.
Adding a new error code is non-breaking; removing or renaming one is a major change. 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`) 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 (`trust` matched → send `\r` → wait for
`bypass` again). Do not probe `trust` first and Enter blindly: the dialog text
stays in the terminal buffer for the life of the session, so a `trust` probe with
`from=buffer` keeps matching on every later run and the Enter lands in a ready
composer. 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.
## Authentication ## Authentication
Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque Optional HTTP Basic (`CODEMAN_USERNAME`/`CODEMAN_PASSWORD`) → opaque
File diff suppressed because one or more lines are too long
+162 -7
View File
@@ -44,6 +44,10 @@ is in [`api-reference.md`](api-reference.md).
the payload at the top level rather than under `data`. Read defensively with the payload at the top level rather than under `data`. Read defensively with
`body.data ?? body`. `body.data ?? body`.
⚠️ A `401` is not an envelope at all: auth is rejected in a request hook that
replies with the bare string `Unauthorized`, so parsing it as JSON throws. Branch on
the status code before you parse, or a missing password looks like a broken endpoint.
**Already driving Codeman from an agent?** The README's **Already driving Codeman from an agent?** The README's
[Programmatic Guide](../README.md#driving-codeman-from-an-agent--programmatic-guide) [Programmatic Guide](../README.md#driving-codeman-from-an-agent--programmatic-guide)
covers the in-session case: the `CODEMAN_MUX`, `CODEMAN_API_URL`, covers the in-session case: the `CODEMAN_MUX`, `CODEMAN_API_URL`,
@@ -152,7 +156,7 @@ for (;;) {
## Seam 3: HTTP API and CLI ## Seam 3: HTTP API and CLI
Around 199 handlers across 21 route files cover sessions, cases, files, cron, Around 200 handlers across 21 route files cover sessions, cases, files, cron,
respawn, Ralph, the orchestrator, search, and admin. Each route module carries an respawn, Ralph, the orchestrator, search, and admin. Each route module carries an
`@fileoverview` describing its endpoints. `@fileoverview` describing its endpoints.
@@ -167,10 +171,12 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"workingDir":"/home/me/project","mode":"claude"}' -d '{"workingDir":"/home/me/project","mode":"claude"}'
# Send a prompt (single-line only) # Send a prompt (single-line only, and it must end with \r: Enter is sent only
# when the input contains a carriage return; without it the text sits on the
# session's prompt unsubmitted)
curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"input":"run the tests","useMux":true}' -d '{"input":"run the tests\r","useMux":true}'
``` ```
`POST .../input` also accepts `clientId` (stable per client, max 128 chars) and `POST .../input` also accepts `clientId` (stable per client, max 128 chars) and
@@ -178,6 +184,124 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \
at-most-once, so retrying after a dropped connection cannot type the prompt at-most-once, so retrying after a dropped connection cannot type the prompt
twice. Omit them entirely rather than sending `null`. twice. Omit them entirely rather than sending `null`.
It also accepts `wait` and `waitTimeout`, which hold the response open until the
session finishes the turn you just started. `wait` is `true` (the default signal
set) or a comma list of `idle,working,stop,blocked,exit`; the result comes back
under `data.wait`. Sending them changes nothing for callers that do not: without
`wait` the response is still `{"success": true, "data": {}}` and the write is still
fire-and-forget. The two interact with `clientId` / `seq` in one way worth knowing:
a **tagged duplicate** (a pair the server already applied) skips the write but still
waits, answering from the session's current state rather than blocking for a
transition that already happened. It reports `"delivered": false, "duplicate": true`.
### Waiting instead of polling
Three calls block until something happens: `GET /api/v1/sessions/:id/wait` (a
lifecycle signal), `GET /api/v1/sessions/:id/wait-output` (a literal string in the
output), and the `wait` field above. Full parameter and response tables are in
[`api-reference.md`](api-reference.md#long-polling-agent-wait). Four things decide
whether your integration works, and the last one is what actually bites:
- **A timeout is a `200` with `wait.timedOut: true`**, not an error. Loop over short
waits rather than issuing one long one, because `tailscale serve` and cloudflared
both cut idle connections and a single 10-minute call is the pattern most likely
to die in the field.
- **`wait.timeoutMs`** is the timeout after server-side clamping (600 s ceiling by
default). Read it rather than assuming you got what you asked for.
- **`stop` and `blocked` only exist for `claude` sessions**, and on a `shell` session
even `idle` fires only once at startup, so send-and-wait there can only time out.
See the Gotchas below.
⚠️ **There is no readiness signal, and skipping readiness is the failure that looks
like success.** A session reports `idle` before its CLI has spawned, and a `claude`
worker in a brand-new case comes up on the CLI's **trust dialog**, which has a ❯
prompt of its own. Prompt it at that moment and the text lands in the dialog, the
`\r` does not get past it, and the session's startup `idle` lands inside the wait
window: the wait resolves on `idle` in a couple of seconds with `timedOut: false`,
indistinguishable from a finished turn. Wait for the pid, then wait for the
composer, answering the dialog only as the bounded fallback.
A worked orchestration: start a worker, get it ready, prompt it, wait, clean up.
```bash
API="${CODEMAN_API_URL:-http://127.0.0.1:3000}" # auto-set in-session, correct scheme included
AUTH=(-u "admin:$CODEMAN_PASSWORD") # omit entirely if no password is set
CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on --https installs (self-signed cert)
# 1. Start a worker session (creates the case if it does not exist yet).
# The guard matters: a TLS or auth failure otherwise leaves SID empty and every
# later step "succeeds" against nothing.
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" \
-H 'Content-Type: application/json' \
-d '{"caseName":"worker-1","mode":"claude"}' | jq -r '.data.sessionId')
[ -n "$SID" ] && [ "$SID" != null ] || { echo "quick-start failed"; exit 1; }
# 2. READINESS: composer marker first, trust dialog only as the bounded fallback.
# Skip this and step 3 reports a turn that never ran. Do NOT probe trust first
# and Enter blindly: the dialog text stays in the buffer for the life of the
# session, so on every later run that probe matches stale text and the Enter
# lands in a ready composer. Match single tokens only: TUI text can arrive
# without its spaces. Stage 1 is short on purpose (an already-trusted case
# matches in <1 s; a first-run case can never pass it and pays it in full).
until [ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ]
do sleep 1; done
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=5000') # composer's status bar = ready
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=2000')
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' -d '{"input":"\r","useMux":true}' >/dev/null
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=45000' >/dev/null
fi
# 3. Send the prompt AND register the wait in one call, so the answer cannot be
# the previous turn's idle state. Single line only, ending in \r (otherwise
# Enter is never sent and this wait times out on a turn that never started).
W=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' \
-d '{"input":"Run the test suite and summarize the failures\r","useMux":true,
"clientId":"orchestrator","seq":1,"wait":"stop,exit","waitTimeout":60000}' \
| jq -c '.data.wait')
# 4. That first wait probably timed out (60 s). Keep going in SHORT waits.
for _ in $(seq 1 30); do
[ "$(jq -r '.timedOut' <<<"$W")" = 'true' ] || break # signal fired, or wait ended
W=$("${CURL[@]}" \
"$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000" | jq -c '.data.wait')
done
jq -r 'if .ended or .aborted then "worker is not running"
elif .timedOut then "still working after 30 waits"
else "signal: \(.signal)" end' <<<"$W"
# 5. Read what it produced, then delete the session YOU created, by exact id.
# ⚠️ NOT /output: its textOutput is empty for every tmux-backed session.
# `tail` counts BYTES, and the payload is terminal data with ANSI in it.
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
"${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
```
Waiting on a marker instead of a signal is the form that works in **every** mode,
and the only one that works on a `shell` session:
```bash
# ⚠️ Split the marker so the typed line never contains it: your own keystrokes echo
# into the output stream, so an unsplit marker matches before the command has run.
# `from=buffer` also catches a marker that printed before the wait registered.
N=$RANDOM
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' \
-d "{\"input\":\"M=DONE; npm test; echo \${M}_$N rc=\$?\r\",\"useMux\":true}"
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode "match=DONE_$N" --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=60000' | jq '.data.wait'
```
For shell scripting, the `codeman` CLI is the same surface without the HTTP For shell scripting, the `codeman` CLI is the same surface without the HTTP
plumbing: plumbing:
@@ -221,10 +345,41 @@ Every one of these has cost somebody real time.
shipped bugs more than once. shipped bugs more than once.
- **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled - **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled
simple-request CSRF, so it is deliberate. Send `application/json`. simple-request CSRF, so it is deliberate. Send `application/json`.
- **Prompts are single-line.** With `useMux: true` the server delivers your text - **Prompts are single-line and must end with `\r`.** The server splits your text
and then Enter as two separate writes, so you do not append `\r` yourself. A and Enter into two separate tmux writes (Ink needs them apart), but it sends the
multi-line string breaks the agent's Ink-based input handling: send one line, Enter **only when the input contains a carriage return**. Without it your text
or split it across calls. sits on the prompt unsubmitted, which is the single most common "the wait
endpoints don't work" report: the wait runs its full timeout on a turn that never
started. Newlines inside the string are stripped rather than rejected, so
`"echo A\necho B\r"` runs the single joined command `echo Aecho B`: send one line
per call.
- **`wait-output`'s `from=now` is not "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 already on screen can satisfy a fresh wait.
Observed live: a marker echoed a minute earlier matched instantly. Use a marker
unique to each call, and build it so the typed line never contains it (your own
keystrokes echo into the stream). Matching is a literal substring, so `regex=` is
rejected with a `400` rather than ignored.
- **`wait-output` matches the normalized PTY stream, not the screen.** ANSI escape
sequences are stripped (the `ESC ( B` charset escape a bash prompt emits on every
line included), a partial escape at a chunk boundary is held back until its tail
arrives, and a match may straddle PTY chunks, so text you printed yourself
matches reliably (`printf STRAD; sleep 1; printf DLEQQ` is matchable as
`STRADDLEQQ`). What can still fail is TUI output: a full-screen TUI positions
words with cursor moves, so its text can reach the matcher **without spaces** and
a multi-word match is unreliable there. Match one short space-free token, ideally
one you printed yourself, and keep it out of the typed line (your own keystrokes
echo into the stream).
- **`stop` and `blocked` never fire for `shell`, `opencode`, `codex`, `gemini` or
`antigravity` sessions.** They come from Claude Code hooks, which no other mode
installs, so only `idle`, `working` and `exit` exist there. Asking for them
explicitly is a `400`; omitting `until` is safe, since the server drops them from
the default set and echoes what it actually waited on as `wait.until`. Even in
`claude` mode, a Docker case needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1` for hooks to
reach the server at all, a remote-SSH case's hooks may never arrive, and a case
written by Codeman < 1.13.0 against an `--https` install carries hook curls
without `-k` that TLS-fail silently — a 1.13.0+ server rewrites them the next
time a session starts in that case.
- **Unwrap the envelope** before reading fields. `data` is not the response body. - **Unwrap the envelope** before reading fields. `data` is not the response body.
## Publishing your integration ## Publishing your integration
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "1.12.2", "version": "1.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "1.12.2", "version": "1.13.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "1.12.2", "version": "1.13.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -159,6 +159,7 @@
"dist", "dist",
"scripts/postinstall.js", "scripts/postinstall.js",
"scripts/fix-node-pty.mjs", "scripts/fix-node-pty.mjs",
"skills",
"LICENSE", "LICENSE",
"README.md" "README.md"
] ]
+274
View File
@@ -0,0 +1,274 @@
---
name: codeman
description: >-
Drive Codeman, the session manager this agent is running inside, over its HTTP API:
list sessions, start worker sessions, send them prompts, block until they finish
(wait / wait-output / send-and-wait), read their output, and clean up. Use when asked
to orchestrate or parallelize work across Codeman sessions, watch another session, or
start and manage workers. Only usable inside a Codeman-managed session
(CODEMAN_MUX=1); refuse to act otherwise.
---
# Driving Codeman from inside a session
You are an agent running inside a Codeman-managed terminal session. Codeman is the
server that spawned you; its HTTP API can start, prompt, watch, and delete other
sessions. Every recipe below was verified live. Full endpoint tables and
troubleshooting: [reference/endpoints.md](reference/endpoints.md). Worked multi-worker
flows: [reference/recipes.md](reference/recipes.md).
## 0. Guard — run this before anything else
```bash
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; }
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Codeman does NOT hand a session the server password. If one is set, the two
# in-reach copies are the data dir's .env (the same fallback `codeman attach`
# uses — hand-authored; nothing ever writes it) and the supervisor definition
# that install.sh wrote the password into, which is where a stock
# password-protected install actually keeps it. The data dir is wherever the
# hook-secret file lives. Values may be quoted or `export`-prefixed.
ENV_FILE="${CODEMAN_HOOK_SECRET_FILE:+${CODEMAN_HOOK_SECRET_FILE%hook-secret}.env}"
envval() { sed -n "s/^\(export \)\{0,1\}$1=//p" "$ENV_FILE" | tail -1 | sed 's/^"\(.*\)"$/\1/; s/^'\''\(.*\)'\''$/\1/'; }
if [ -z "${CODEMAN_PASSWORD:-}" ] && [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then
CODEMAN_USERNAME=$(envval CODEMAN_USERNAME)
CODEMAN_PASSWORD=$(envval CODEMAN_PASSWORD)
fi
if [ -z "${CODEMAN_PASSWORD:-}" ]; then # stock installs: install.sh puts it in the service definition
UNIT="$HOME/.config/systemd/user/codeman-web.service"
PLIST="$HOME/Library/LaunchAgents/com.codeman.web.plist"
if [ -f "$UNIT" ]; then
CODEMAN_PASSWORD=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$UNIT" | head -1)
elif [ -f "$PLIST" ]; then
CODEMAN_PASSWORD=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$PLIST" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
fi
fi
AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:$CODEMAN_PASSWORD")
CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on https (self-signed cert)
```
- If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
you are not part of is not yours to drive.
- **A 401 is plain text, not the JSON envelope**, so on a password-protected server
every `jq` in these recipes dies with `jq: parse error` instead of showing
`UNAUTHORIZED`. If that happens, check the status with `-w '%{http_code}'`; if it
is 401 and neither fallback above found a credential, **stop and tell the user
you need credentials**. The hook-secret bypass covers only `/api/hook-event` and
`/api/status-telemetry`, never session control.
- These endpoints first ship in Codeman **1.13.0**, but do not gate on the version
number: a dev build can serve them while reporting an older version. Probe
instead: `GET .../wait` on a real session id answering 404 with an `.error`
starting `Route ` means the server predates the wait endpoints (fall back to
polling `GET .../terminal?tail=` and say so); `Session ... not found` means your
session id is wrong, not the server.
## 1. Safety rules — read before any mutating call
You are yourself a session on this server, and the API has **no undo**.
- **Never act on your own session — and know that this check is the ONLY guard.**
The server has no self-protection: a session that DELETEs its own id succeeds and
dies silently (verified live). Session ids appear in both full and 8-character
forms (Docker cases export a truncated `$SELF`; mux names and UI surfaces carry
8-char ids), so compare by prefix **in both directions**, never by equality:
```bash
is_self() { case "$1" in "$SELF"*) return 0 ;; esac; case "$SELF" in "$1"*) return 0 ;; esac; return 1; }
```
One-directional or equality checks each miss a real combination (full `$SELF` vs
a target you transcribed in 8-char form, or truncated `$SELF` vs a full target)
and the miss deletes you. Check `is_self` before every `DELETE`, kill, respawn,
or input call.
- **Mutating calls you may make unprompted** (this is an allowlist):
`POST /api/v1/quick-start`, `POST /api/v1/sessions/:id/input`, and
`DELETE /api/v1/sessions/:id` **only** for a session you created in this
conversation, by exact id. Keep a list of the ids you create. Everything else
mutating needs the user to have asked for it.
- **Never call these** unless the user explicitly asked, naming the target:
- `DELETE /api/cases/:name` — recursively **deletes a real directory of the user's
code** from disk. One wrong case name destroys work that was never yours.
- `DELETE /api/sessions` (no id) and `DELETE /api/subagents` (no id) — bulk kills.
- respawn / ralph / orchestrator / cron mutations — respawn runs `/clear` (wipes a
conversation), orchestrator state is a single global slot, cron jobs outlive you.
- `PUT /api/settings`, `POST /api/system/update` — global UI settings; server restart.
- Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface.
- Sessions count against a 50-session cap and case creation is uncapped: clean up every
session you start, and don't retry `quick-start` in a loop.
## 2. Rules of the road
- **End every input with `\r`** — literally the two characters `\r` inside the JSON
string. Codeman types the text and sends Enter **only when the input contains a
carriage return**; without it your command sits unsubmitted on the worker's prompt
and everything downstream times out. `{"input":"run the tests\r",...}`. No response
field catches this: `delivered:true` means "written to the pane", **not**
"submitted" — a `\r`-less send still reports `delivered:true` and then every wait
times out, which is why the loops below are bounded and check the terminal.
- **Single-line input only.** Newlines are stripped; one line per call.
- **Build request bodies with `jq -n` for any prompt you did not author as a
literal.** The inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first
double quote, backslash, or `$` in a real prompt:
```bash
BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}')
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' --data-binary "$BODY"
```
- **Exactly-once delivery**: always send a stable `clientId` and a monotonic
per-session `seq` on `POST .../input`. A retry after a dropped connection then
cannot double-type the prompt. Increment `seq` for each NEW input; reuse the same
pair only to re-ask about the same delivery.
- **Envelope**: success is `{"success":true,"data":…}`, errors are
`{"success":false,"error","errorCode"}`. Read `.data`. Use `/api/v1/*` paths.
- **A wait timeout is HTTP 200**, `{wait:{timedOut:true,signal:null}}` — not an error.
Loop over short waits (60 s); proxies cut long-idle connections. Timeouts are
**clamped** (ceiling 600 s): read back `wait.timeoutMs` for what was applied.
- **`stop` and `blocked` fire for `claude` sessions only** (Claude Code hooks). On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`, requesting them explicitly is a
400 — and lifecycle transitions there are coarse (a short shell command may emit
**no** `idle` transition at all, verified live), so synchronize those modes with
output markers, not signals.
- **Your typed command echoes into the output stream**, so a marker that appears
verbatim in the input line matches **before the command runs**. Always split the
marker (recipe below), keep it unique per call, and use `from=buffer` so a marker
that printed before your wait landed is still found. Matching is literal — no regex.
- **Match single space-free tokens against TUI output.** A full-screen TUI (claude,
codex, …) positions text with cursor movements, not literal spaces, so the stripped
stream can read `Yes,Itrustthisfolder` and a multi-word match is unreliable there —
whether a phrase keeps its spaces depends on how the TUI happened to draw it
(observed live: some match, some never fire). Plain command output (shell workers,
`echo` lines) keeps real spaces.
## 3. Recipes (each verified live)
**List sessions / find yourself** — metadata only, safe to poll:
```bash
"${CURL[@]}" "$API/api/v1/sessions" | jq '.data[] | {id, name, mode, status}'
"${CURL[@]}" "$API/api/v1/sessions" | jq --arg s "$SELF" '.data[] | select(.id | startswith($s))'
```
**Start a claude worker and wait until it is actually ready.** A new session reports
`idle` before its CLI has spawned, and a brand-new case shows a **trust dialog**
first, so neither "wait for idle" nor "wait for ❯" means ready (the trust dialog
contains `❯` too — observed live). Codeman *can* auto-accept that dialog itself, but
the accept rides a stream match that misses on some runs (both outcomes seen live),
so wait for the composer first and handle the dialog only as the bounded fallback —
never send a blind Enter up front (if auto-accept already fired, it lands in the
composer). Stage 1 is short on purpose: an already-trusted case matches `bypass` in
under a second, while a **virgin case can never pass stage 1** (the dialog is up, so
the composer is not) and always pays it in full before the fallback runs — the long
budget belongs to stage 3, after the dialog is answered:
```bash
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d '{"caseName":"worker-1","mode":"claude"}' | jq -r '.data.sessionId')
for _ in $(seq 1 30); do # bounded: a bad SID would otherwise poll forever
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
done
# ⚠️ pid != null proves STARTUP only, never life: a worker that later dies inside
# its pane keeps status "idle" and a pid (the local tmux attach client, not the
# worker). The death check is wait?until=exit, below.
CID="agent-$$"; SEQ=1
# the composer's status bar ("bypass permissions on") is the ready marker — Codeman
# spawns claude in bypass mode. Single-token matches only: TUI text is space-less.
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# composer never appeared → the trust dialog is probably still up; accept it once
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
SEQ=$((SEQ+1))
fi
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
jq -e '.data.wait.matched' <<<"$R" >/dev/null || \
{ echo "worker $SID never became ready; inspect terminal?tail="; }
fi
```
**Send a prompt and wait for the turn to finish** (claude workers — the call to
prefer). It registers the waiter *before* typing, closing the race where a separate
wait sees the previous turn's idle state. Loop by resending the **identical** request:
the repeat is a tagged duplicate (same `clientId`+`seq`) that does not retype but
answers from the session's current state. Verified: the stop hook resolves this in
seconds; a duplicate resend answers in ~20 ms without retyping.
```bash
for TRY in $(seq 1 10); do # BOUNDED: a \r-less send never produces a signal and resends are no-op duplicates
R=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"run the tests, then summarize in one line\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ',"wait":true,"waitTimeout":60000}')
if jq -e '.data.wait.timedOut' <<<"$R" >/dev/null; then
[ "$TRY" = 2 ] && "${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
| jq -r '.data.terminalBuffer' | tail -5 # two straight timeouts: prompt sitting unsubmitted?
continue
fi
# Resolved — but a duplicate answering immediately reports the session's CURRENT
# state ("it is idle now"), NOT that a new turn ran. A \r-less send lands exactly
# here on try 2 (verified live), so check the terminal before believing it:
if jq -e '.data.duplicate and .data.wait.immediate' <<<"$R" >/dev/null; then
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" | jq -r '.data.terminalBuffer' | tail -5
# your prompt still on the ❯ composer line = never submitted (missing \r);
# submit it with {"input":"\r"} (the only recovery), then loop again
fi
break
done
SEQ=$((SEQ+1)); jq '.data.wait.signal, .data.status' <<<"$R"
```
Read the outcome in this order: `wait.signal != null` → done (`stop` is definitive;
`idle` is heuristic) — **unless** it arrived as `duplicate:true` + `immediate:true`,
which only says the session is idle *now* and must be confirmed from the terminal
(above); `wait.timedOut` → loop again (bounded); `wait.ended` → session gone, stop.
If the loop exhausts its cap, do not keep looping: read the terminal, report what
you see, and remember that a still-typed-but-unsubmitted prompt (missing `\r`) can
only be recovered by submitting it with `{"input":"\r"}`.
**Shell worker + completion marker** — the pattern for `shell` mode (no hooks there).
The typed line must not contain the marker verbatim (the input echo would match
instantly — observed live), so build it with a variable the worker's shell expands:
```bash
N="${RANDOM}_$$"; MARK="DONE_$N" # unique per call: tmux repaints replay old text
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}'
SEQ=$((SEQ+1))
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode "match=$MARK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=120000' \
| jq -r '.data.wait | {matched, snippet}'
```
The typed line shows `${M}_…`, the real output shows `DONE_… rc=<exit code>`, and the
snippet carries the exit code back to you.
**Read a worker's output** — the terminal buffer, tail in **bytes** (`textOutput` in
`GET .../output` stays empty for interactive sessions; don't use it):
```bash
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=3000" | jq -r '.data.terminalBuffer' \
| sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g' | grep -v '^[[:space:]]*$' | tail -30
```
Avoid `?full=1` (entire tmux scrollback, a context bomb) unless doing a post-mortem.
**Detect a dead worker cheaply**: `GET .../wait?until=exit&timeout=60000` answers
immediately (`signal:"exit"`, `immediate:true`) if the PTY is gone — including a
worker that exited *inside* its pane, which `GET .../sessions/:id` keeps reporting
as `status:"idle"` with a pid (that pid is the local tmux attach client, not the
worker). The wait routes are the only liveness check; a worker dying while a wait
is parked resolves it within ~3 s. A session deleted mid-wait resolves in ~1 s.
**Clean up** — only ids you created, `is_self`-checked, one at a time:
```bash
is_self "$SID" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
```
Everything else (endpoint tables, per-mode signal table, error codes, capacity
limits, Docker/remote caveats): [reference/endpoints.md](reference/endpoints.md).
Fan-out orchestration and blocked-worker handling:
[reference/recipes.md](reference/recipes.md).
+214
View File
@@ -0,0 +1,214 @@
# Codeman API reference for agents
Loaded on demand from the `codeman` skill. Assumes the guard variables from SKILL.md
(`$API`, `$SELF`, `"${CURL[@]}"`). Canonical contract: `docs/api-reference.md` in the
Codeman repo; this file is the agent-relevant subset, verified live.
## Envelope and errors
Every JSON response: `{"success":true,"data":…}` or
`{"success":false,"error":"…","errorCode":"…"}`. Branch on `errorCode`:
| `errorCode` | HTTP | Meaning |
|-------------|------|---------|
| `INVALID_INPUT` | 400 | malformed request; the message names the bad field |
| `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope — `jq` dies with a parse error, see the guard in SKILL.md |
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
| `SESSION_BUSY` | 409 | this session's waiter cap (16, combined signal+output) is full |
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
| `OPERATION_FAILED` | 422 | well-formed but could not be completed |
| `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full — back off; switching sessions will not help |
| `INTERNAL_ERROR` | 500 | server bug |
`SESSION_BUSY` vs `RATE_LIMITED` on the wait endpoints is deliberate: the first means
"too many waiters on *this* session", the second means the *pool* is full.
## Sessions
| Task | Call |
|------|------|
| list sessions (metadata only, ~1.5 KB each, safe to poll) | `GET /api/v1/sessions` |
| one session (has `.data.pid`, `null` until the PTY spawns) | `GET /api/v1/sessions/:id` — ⚠️ **not a liveness check**: a worker that dies inside its pane keeps `status:"idle"` and a pid (the tmux attach client); `wait?until=exit` is the death check |
| unified list incl. history | `GET /api/v1/sessions/unified` → `.data.sessions[]` (NOT `.data[]`), and it folds in transcript history from the whole machine — never use it to verify cleanup; `GET /api/v1/sessions` is the cleanup check |
| start case + session in one call | `POST /api/v1/quick-start` |
| send input | `POST /api/v1/sessions/:id/input` |
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer` |
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
| background agents of a session | `GET /api/v1/subagents` |
| server status / version | `GET /api/v1/status` → `.data.version` |
| delete one session (yours, `is_self`-checked) | `DELETE /api/v1/sessions/:id` |
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
JSON-stream path). Verified empty on live claude and shell sessions. Read
`terminal?tail=` instead and strip ANSI:
```bash
… | jq -r '.data.terminalBuffer' | sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g'
```
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
— `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing — do not retry it in a loop, and remember the name.
`POST /api/v1/sessions/:id/input` body:
`{"input":"one line\r","useMux":true,"clientId":"agent-1","seq":1}` plus optionally
`"wait"` / `"waitTimeout"` (below).
- ⚠️ **The input must contain `\r`** (the JSON escape, i.e. a real carriage return)
**or Enter is never sent**: the text is typed onto the worker's prompt and sits
there unsubmitted. Verified live — this is the number-one silent failure, and no
response field catches it: `delivered:true` means "written to the pane", not
"submitted". A `\r`-less send with `wait` reports `delivered:true` and then every
wait on that turn times out. Without `wait`, fire-and-forget returns an **empty**
`{"success":true,"data":{}}` — no `delivered`, no `duplicate`; those fields exist
only on the `wait` variant, so a fire-and-forget flow gets no delivery
confirmation at all.
- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
a dialog), send `{"input":"\r"}`.
- `clientId`+`seq` give exactly-once delivery: the server applies each pair at most
once. Increment `seq` per new input.
## The wait primitives
Three bounded long-polls. Shared semantics:
- **Timeout = HTTP 200** with `wait.timedOut:true`. Loop over short waits (60 s);
`tailscale serve` / cloudflared cut idle connections.
- Timeouts are **clamped** to `[1000, 600000]` ms (operator-tunable); the applied
value is echoed as `wait.timeoutMs` — read it back, never assume.
- All three nest the result under `.data.wait`, same shape, so one helper parses all.
- `.data.status` (post-wait `SessionStatus`) and `.data.limitPaused` ride along.
`limitPaused:true` means the session is paused on a usage limit and will emit
nothing until reset — a timeout is then *expected*; do not retry hard, and do not
kill the worker.
### Signals by mode
| Signal | Meaning | Available for |
|--------|---------|---------------|
| `idle` | output stabilized + prompt detected — heuristic, can flap mid-turn | every mode |
| `working` | session started producing output | every mode |
| `stop` | Claude Code `stop` hook — the definitive end-of-turn | `claude` only |
| `blocked` | `permission_prompt` / `elicitation_dialog` hook — the worker needs an answer | `claude` only |
| `exit` | PTY exited or session deleted | every mode |
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
`stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
`["idle","exit"]` on shell); requesting them *explicitly* there is a 400 naming the
mode. ⚠️ On hook-less modes the lifecycle signals are also **coarse in practice**: a
short shell command produced **no** `idle` transition within 60 s (verified live), so
a `fresh=1` / fresh-delivery wait can burn its whole timeout while the work finished
long ago. Synchronize hook-less modes with `wait-output` markers instead.
Two more places hooks go missing even in claude mode: **Docker cases** need
`CODEMAN_DOCKER_BRIDGE_HOOKS=1` on the server (without it only `idle`/`working`/
`exit` arrive), and **remote-SSH cases** run the agent on another host whose hooks may
never reach this server. When unsure, ask for `stop,idle,exit`.
⚠️ **Signals are edge-triggered with no history.** A signal that fires while no
waiter is registered is gone; no later wait can observe it (`until=stop` on a worker
whose turn already ended just times out, with or without `fresh` — verified live).
Register the waiter before the event can happen: send-and-wait does exactly that,
and `wait-output` markers with `from=buffer` are latched by construction. Never
fire-and-forget N prompts and then gather signal-waits worker by worker; every
worker that finishes before its gather is unobservable (see recipes.md Flow 3b).
### `GET /api/v1/sessions/:id/wait`
| Param | Default | Notes |
|-------|---------|-------|
| `until` | `stop,idle,exit` | comma list; unknown token → 400 naming it |
| `timeout` | 60000 | ms, clamped; applied value echoed as `wait.timeoutMs` |
| `fresh` | `0` | `1` requires an actual *transition*, ignoring the state at call time |
⚠️ A session whose PTY has not spawned (`pid:null`) or has exited counts as `exit`
**right now**: with the default set the call answers immediately
(`signal:"exit", immediate:true`). That is how you detect a dead worker cheaply — but
it also means "wait for my just-created session" needs the readiness recipe in
SKILL.md, not this endpoint.
### `GET /api/v1/sessions/:id/wait-output`
| Param | Default | Notes |
|-------|---------|-------|
| `match` | required | literal substring, 1–200 chars, ANSI-stripped; chunk-straddling matches found; **no regex** — a `regex=` param is a 400 |
| `nocase` | `0` | case-insensitive compare; snippet keeps original casing |
| `from` | `now` | `buffer` scans the tail (~256 KB) of existing output first |
| `timeout` | 60000 | same clamp |
Four traps, all observed live:
1. **The echo of your own typed command is output.** A marker appearing verbatim in
the input line matches the moment the text is typed, before the command runs.
Split the marker with a shell variable: send `M=DONE; …; echo ${M}_1234\r`, wait
on `DONE_1234`.
2. **`from=now` misses text printed before the wait landed** — a marker echoed just
before the request registered timed out at full length. After sending a command,
always wait with `from=buffer`.
3. **`from=now` can also match too much**: tmux repaints old screen content as
ordinary output on attach/resize/redraw, so a *generic* marker (`BUILD OK`)
matches stale text. Unique-per-call markers (`DONE_$RANDOM`) make both `from`
modes safe.
4. **TUI output can be space-less in the stream.** Full-screen TUIs (claude, codex,
…) position words with cursor-movement escapes rather than literal spaces, so
the stripped stream can read `Yes,Itrustthisfolder` while the pane shows the
spaced phrase. Whether a given phrase keeps its spaces depends on how the TUI
drew it (observed live: some multi-word matches fire, some never do), so treat
multi-word matches against TUI screens as unreliable and match a **single
space-free token** (`trust`, `bypass`). Plain command output (shell workers,
`echo` lines) keeps real spaces and multi-word matches work there.
Build the query with `-G --data-urlencode` (a `+` in a hand-built query decodes to a
space). Result extras: `wait.matched`, `wait.match`, `wait.snippet` (bounded window
around the match, blank runs collapsed — the snippet is often all you need to read).
### `POST /api/v1/sessions/:id/input` with `wait`
| Field | Notes |
|-------|-------|
| `wait` | `true` (default signal set) or the same comma grammar as `until`; absent = historical fire-and-forget |
| `waitTimeout` | ms, same clamp |
Registers the waiter **before** typing, which closes the race where send-then-wait
sees the previous turn's idle state and returns instantly. Response adds `delivered`
and `duplicate` beside the standard `wait` object.
A **tagged duplicate** (same `clientId`+`seq` already applied) does not retype but
still honors `wait`, answering from the session's *current* state instead of
requiring a new transition (`delivered:false, duplicate:true` — verified: ~20 ms,
command ran exactly once). That is what makes the resend-identical-request loop in
SKILL.md correct: iteration 1 delivers and needs a transition; later iterations
resolve immediately if the turn ended in between. ⚠️ The flip side: a duplicate's
`immediate:true` answer is the current state and nothing more — an idle worker
whose prompt was never submitted (missing `\r`) produces the same
`signal:"idle", immediate:true` as one that finished the turn. Confirm from
`terminal?tail=` before reporting success; SKILL.md's loop shows where.
### Outcome parsing, in order
1. `wait.signal != null` (or `wait.matched == true`) — the thing happened.
`wait.immediate:true` rides along and means the condition already held at call
time; if that is not what you meant, you wanted `fresh=1` or send-and-wait.
2. `wait.timedOut` — poll boundary; loop again.
3. `wait.ended` — session deleted/torn down mid-wait; stop looping.
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| every curl fails with a certificate error | you dropped `-k`; `CODEMAN_API_URL` is HTTPS with a self-signed cert |
| `jq: parse error` on every call | plain-text 401s: the server has a password. Check with `-w '%{http_code}'`, use the guard's `.env` fallback, and if no `.env` exists, stop and ask the user for credentials |
| input arrives but nothing happens; later waits all time out | the input had no `\r`, so Enter was never sent; the text is sitting on the worker's prompt. **Submitting it with `{"input":"\r"}` is the ONLY recovery** — Ctrl+U (0x15) and Esc do NOT clear the composer (verified live) — and the flush costs one turn in which the worker reasons about the junk; open the next real prompt with "ignore the garbled line above:" |
| `GET .../sessions/$CODEMAN_SESSION_ID` 404s | Docker case: the env id is truncated to 8 chars; find yourself with `startswith($SELF)`, and always self-compare by prefix, in both directions |
| `CODEMAN_MUX` unset but you seem to be in a session | remote-SSH case: the env vars are not exported there. Fail closed — refuse to act |
| connection refused from inside a container | a loopback-bound server is unreachable from a container, and `CODEMAN_DOCKER_BRIDGE_HOOKS=1` does **not** fix that: it opens a hooks-only listener, so hook events start flowing but `/api/v1/*` stays refused. Driving the API from inside a Docker case needs a reachable bind (an operator decision); report it, don't retry |
| wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare) — poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server |
| wait on `stop` never resolves | non-claude mode, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept missed; use the readiness recipe in SKILL.md (wait for `bypass` first, accept the dialog only as the bounded fallback) |
| `wait-output` times out although the pane shows the text | multi-word match against a TUI screen; the stream has no spaces there — match one token |
| `wait-output` matched instantly with stale text | generic marker + tmux repaint; use `DONE_$RANDOM` |
| 409 `SESSION_BUSY` on a wait | too many concurrent waiters on that session (cap 16 combined); reuse one wait per worker |
| 429 `RATE_LIMITED` on a wait | global/owner waiter pool full; back off, do not switch sessions |
+249
View File
@@ -0,0 +1,249 @@
# Worked orchestration flows
Loaded on demand from the `codeman` skill. Every flow assumes the guard preamble from
SKILL.md ran (`$API`, `$SELF`, `"${CURL[@]}"`, `is_self`). Track every session id you
create; delete them (and only them) when done. Remember the two silent killers:
**every input ends with `\r`**, and **markers must be split** so the typed-line echo
does not match them.
## Flow 1: claude worker, end to end
Start a worker, get it truly ready (trust dialog included), give it a task, wait for
the turn to finish, read the answer, clean up. Verified live: the stop hook resolves
the send-and-wait within seconds of the turn ending.
```bash
# 1. start (returns before the CLI inside is ready)
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d '{"caseName":"worker-tests","mode":"claude"}' | jq -r '.data.sessionId')
CREATED+=("$SID") # the cleanup list
CID="agent-$$"; SEQ=1
# 2. readiness. "wait for idle" or "wait for ❯" is NOT readiness: a fresh session
# reports idle before anything spawned, and the first-run trust dialog contains ❯.
# Codeman CAN auto-accept that dialog, but the accept misses on some runs (both
# outcomes seen live), so: composer marker first, dialog only as the bounded
# fallback (a blind Enter up front would land in an already-ready composer).
# Stage 1 is SHORT on purpose: an already-trusted case matches in <1 s, while a
# virgin case can never pass it (the dialog is up) and always pays it in full —
# the long budget belongs to stage 3, after the dialog is answered.
# Single-token matches only: TUI text is space-less in the stream.
for _ in $(seq 1 30); do
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
done
# (pid != null proves startup only — a worker that later dies inside its pane keeps
# status "idle" and a pid. The death check is wait?until=exit.)
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
SEQ=$((SEQ+1))
fi
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
jq -e '.data.wait.matched' <<<"$R" >/dev/null || echo "worker $SID not ready; inspect terminal?tail="
fi
# 3. send-and-wait, looping on the IDENTICAL request (tagged duplicate: no retype).
# BOUNDED (a \r-less send would otherwise loop forever), body built with jq -n so
# quotes/backslashes/$ in a real prompt survive; note the appended \r.
PROMPT='run the unit tests and summarize failures in one line'
BODY=$(jq -n --arg p "$PROMPT" --arg c "$CID" --argjson s "$SEQ" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:60000}')
for TRY in $(seq 1 10); do
R=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' --data-binary "$BODY")
if jq -e '.data.wait.timedOut' <<<"$R" >/dev/null; then
jq -e '.data.limitPaused' <<<"$R" >/dev/null && sleep 60 # usage-limit pause: silence is expected
[ "$TRY" = 2 ] && "${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
| jq -r '.data.terminalBuffer' | tail -5 # is the prompt sitting unsubmitted?
continue
fi
# Resolved — but duplicate + immediate is only "the session is idle NOW", which a
# never-submitted (\r-less) prompt also produces. Check before believing it:
if jq -e '.data.duplicate and .data.wait.immediate' <<<"$R" >/dev/null; then
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
| jq -r '.data.terminalBuffer' | tail -5
# prompt still on the ❯ composer line = never submitted; {"input":"\r"} is the
# only recovery, then loop again
fi
break
done
SEQ=$((SEQ+1))
# 4. interpret
case "$(jq -r '.data.wait.signal' <<<"$R")" in
stop) : ;; # definitive end of turn
idle) : ;; # heuristic — and if it rode a duplicate with
# immediate:true, it proves nothing ran (step 3)
exit) echo "worker died" ;;
null) jq -e '.data.wait.ended' <<<"$R" >/dev/null && echo "worker deleted mid-wait" ;;
esac
# 5. read the answer: terminal tail (BYTES), ANSI-stripped. textOutput stays empty
# for interactive sessions; terminal?full=1 is a context bomb.
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=4000" | jq -r '.data.terminalBuffer' \
| sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g' | grep -v '^[[:space:]]*$' | tail -30
# 6. clean up — exact id, own list only, self-check
is_self "$SID" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
```
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
re-ask about the same delivery (the duplicate-wait loop above).
## Flow 2: shell worker running a build, marker-synchronized
`shell` sessions have no hooks (`stop`/`blocked` are a 400 there), and their lifecycle
signals are coarse — a short command may emit no `idle` transition at all (verified
live), so send-and-wait can burn its whole timeout. The reliable pattern is a split,
unique marker plus `wait-output from=buffer`:
```bash
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d '{"caseName":"builder","mode":"shell"}' | jq -r '.data.sessionId')
CREATED+=("$SID")
for _ in $(seq 1 30); do
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
done
# Split marker: the typed line carries ${M}_N, only the OUTPUT carries DONE_N.
# An unsplit marker matches the echo of your own keystrokes before the build runs.
N="${RANDOM}_$$"; MARK="DONE_$N"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"build-'$$'","seq":1}'
for TRY in $(seq 1 30); do # BOUNDED (30 min): a \r-less send makes an uncapped loop infinite
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode "match=$MARK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000')
jq -e '.data.wait.matched' <<<"$R" >/dev/null && break
jq -e '.data.wait.ended' <<<"$R" >/dev/null && { echo "worker gone"; break; }
[ "$TRY" = 2 ] && "${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" \
| jq -r '.data.terminalBuffer' | tail -5 # command still sitting unsubmitted?
done
jq -r '.data.wait.snippet' <<<"$R" # e.g. "DONE_123_456 rc=0" — the exit code rides the marker line
```
## Flow 3: fan out N workers, gather as each finishes
Start everything first, then gather. One in-flight wait per worker — the per-session
waiter cap is 16 and abandoned concurrent waits pile up against it.
```bash
declare -A WORKER MARKS
for task in lint typecheck unit; do
SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d '{"caseName":"fan-'"$task"'","mode":"shell"}' | jq -r '.data.sessionId')
WORKER[$task]=$SID; CREATED+=("$SID")
done
for task in "${!WORKER[@]}"; do
SID=${WORKER[$task]}
for _ in $(seq 1 30); do
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
done
N="${task}_${RANDOM}"; MARKS[$task]="DONE_$N"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"M=DONE; npm run '"$task"'; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"fan-'$$'","seq":1}'
done
for task in "${!WORKER[@]}"; do # sequential gather; each wait blocks until that worker is done
for TRY in $(seq 1 30); do # BOUNDED per worker, same reasoning as Flow 2
R=$("${CURL[@]}" -G "$API/api/v1/sessions/${WORKER[$task]}/wait-output" \
--data-urlencode "match=${MARKS[$task]}" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000')
jq -e '.data.wait.matched or .data.wait.ended' <<<"$R" >/dev/null && break
done
echo "$task: $(jq -r '.data.wait.snippet // "worker gone"' <<<"$R" | tail -1)"
done
```
## Flow 3b: fan out N CLAUDE workers
Send-and-wait is synchronous, so the shell-flow shape ("send everything, then
gather") does not translate directly: the send *is* the wait, and worker 2's prompt
would not go out until worker 1's turn ended. Two working patterns, both verified
live (and one anti-pattern, measured failing, replaced by B):
**A. Background the send-and-waits** (simplest; each resolved on `stop` while the
other was still running):
```bash
sendwait() { # $1=sid $2=prompt $3=seq — assumes the worker passed Flow 1's readiness
local body; body=$(jq -n --arg p "$2" --argjson s "$3" \
'{input:($p+"\r"),useMux:true,clientId:"fan-'$$'",seq:$s,wait:true,waitTimeout:600000}')
"${CURL[@]}" -X POST "$API/api/v1/sessions/$1/input" \
-H 'Content-Type: application/json' --data-binary "$body" > "/tmp/fan-$1.json"
}
( sendwait "$SID1" 'refactor module A and reply DONE' 2 & \
sendwait "$SID2" 'write tests for module B and reply DONE' 2 & wait )
jq -c '.data.wait | {signal, waitedMs}' /tmp/fan-"$SID1".json /tmp/fan-"$SID2".json
```
One in-flight wait per worker keeps you far from the 16-per-session waiter cap.
**B. Fire-and-forget, then gather with output markers.** If you must send every
prompt before waiting on anything, do **not** gather with signal waits: signals
are edge-triggered with no history, so a `stop` that fires before the gather
reaches that worker is gone and unobservable afterwards — `fresh=1` cannot help,
and neither can omitting it (measured: worker 2's turn ended at +2 s, its
sequential `until=stop,exit&fresh=1` gather burned its full bounded 300 s and
reported nothing). Gather instead on a marker each worker prints itself, which
`from=buffer` re-finds no matter when it appeared:
```bash
# SIDS[1], SIDS[2] = worker ids that already passed Flow 1's readiness.
# The typed prompt must NOT contain the finished marker verbatim (your keystrokes
# echo into the output stream and would match instantly), so ask for it in halves:
declare -A TOK
for i in 1 2; do
TOK[$i]="${RANDOM}_$i"
BODY=$(jq -n --arg p "do task $i; when completely done print the word WORKDONE immediately followed by _${TOK[$i]}" \
--arg c "fan-$$" --argjson s 2 '{input:($p+"\r"),useMux:true,clientId:$c,seq:$s}')
"${CURL[@]}" -X POST "$API/api/v1/sessions/${SIDS[$i]}/input" \
-H 'Content-Type: application/json' --data-binary "$BODY"
done
for i in 1 2; do # order no longer matters: the marker is latched in the buffer
"${CURL[@]}" -G "$API/api/v1/sessions/${SIDS[$i]}/wait-output" \
--data-urlencode "match=WORKDONE_${TOK[$i]}" --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=600000' | jq -c '.data.wait | {matched, snippet}'
done
```
Use A unless you genuinely need to send everything before waiting on anything: A
needs no marker discipline, and resolves on the definitive `stop` instead of on
the worker remembering to print a token.
## Flow 4: watch for a worker stuck on a permission prompt
Claude workers can block on a permission dialog. `blocked` is a wait signal
(claude-mode only), so watch for it and surface the question to the user instead of
guessing an answer:
```bash
R=$("${CURL[@]}" "$API/api/v1/sessions/$SID/wait?until=stop,blocked,exit&timeout=60000")
if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" | jq -r '.data.terminalBuffer' \
| sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' | grep -v '^[[:space:]]*$' | tail -15
# show this to the user and ask how to answer; do NOT auto-confirm another
# session's permission prompt
fi
```
## Cleanup discipline
At the end of the conversation (or on abort), delete exactly what you created:
```bash
for id in "${CREATED[@]}"; do
is_self "$id" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id"
done
```
- Only ids from your own `CREATED` list. Never enumerate `/api/v1/sessions` and
delete by pattern; other sessions belong to the user.
- If you created a *case* purely as scratch and the user confirmed it is disposable,
`DELETE /api/v1/cases/:name` removes it — but that recursively deletes the
directory from disk, so never do it without the user's explicit go-ahead for that
exact name.
+112
View File
@@ -0,0 +1,112 @@
/**
* @fileoverview Bounds for the agent wait primitives.
*
* These back the blocking endpoints an agent uses to orchestrate other sessions
* (`GET /api/sessions/:id/wait`, `GET /api/sessions/:id/wait-output`, and the
* `wait` field on `POST /api/sessions/:id/input`). Plan: `docs/agent-control-plan.md`.
*
* Why every value is bounded:
* - An unbounded long-poll is a socket leak. A caller that asks for a 12-hour wait
* and walks away holds a connection (and a waiter, and a timer) until the process
* restarts, so `MAX_WAIT_MS` is a hard ceiling applied server-side.
* - `DEFAULT_WAIT_MS` is deliberately short (60s). Production is reached through
* `tailscale serve` and users also run cloudflared tunnels; both can cut an idle
* connection, so the documented pattern is a client-side loop over short waits
* rather than one very long call. Fastify itself is happy to hold the request
* (`requestTimeout` defaults to 0, and `keepAliveTimeout` applies between
* requests, not to an in-flight one), the intermediaries are the constraint.
* - The waiter caps mirror `MAX_SSE_CLIENTS` in `map-limits.ts`: each pending
* waiter costs an open HTTP response plus a timer, so the pool is capped rather
* than queued. Exceeding a cap is an explicit error, never a silent wait.
* - There are THREE caps, not two, because a process-wide pool with no per-user
* dimension lets one user deny the primitive to everyone else. `middleware/auth.ts`
* already treats that shape as a bug (its `userFailures` bucket exists so "one user
* behind a NAT can't lock out everyone else"); `MAX_WAITERS_PER_OWNER` is the same
* idea for waiters. It applies only when the caller has an owner, so single-user
* mode is byte-identical to having no owner cap at all.
*
* All values are env-overridable and clamped to sane hard bounds, so a typo in an
* env var degrades to the default instead of disabling the protection.
*
* @module config/agent-wait
*/
/** Absolute floor for any wait, in ms. Sub-second waits are polling, not waiting. */
export const MIN_WAIT_MS = 1_000;
/** Ceiling the operator-configurable maximum is itself clamped to. */
const HARD_MAX_WAIT_MS = 3_600_000;
function envInt(name: string, fallback: number, min: number, max: number): number {
const raw = parseInt(process.env[name] || '', 10);
if (!Number.isFinite(raw) || raw <= 0) return fallback;
return Math.max(min, Math.min(max, raw));
}
/** Longest a single wait may block. Requests above this are clamped down, not rejected. */
export const MAX_WAIT_MS = envInt('CODEMAN_WAIT_MAX_MS', 600_000, MIN_WAIT_MS, HARD_MAX_WAIT_MS);
/** Used when the caller omits `timeout`. Never exceeds MAX_WAIT_MS. */
export const DEFAULT_WAIT_MS = Math.min(
envInt('CODEMAN_WAIT_DEFAULT_MS', 60_000, MIN_WAIT_MS, HARD_MAX_WAIT_MS),
MAX_WAIT_MS
);
/** Concurrent waiters (signal + output) allowed against one session. */
export const MAX_WAITERS_PER_SESSION = envInt('CODEMAN_WAIT_MAX_PER_SESSION', 16, 1, 256);
/**
* Ceiling the operator-configurable total is itself clamped to.
*
* 512 rather than the 4096 this started at. Every other knob in this file degrades
* safely on a bad value; a 4096 ceiling instead lets a well-meaning operator turn the
* protection into the problem, since 4096 concurrent held responses (each an open
* socket, a timer and a pending promise) exceeds the 1024 soft `RLIMIT_NOFILE` that is
* still the default on most Linux distros, before counting PTYs, SSE clients and
* WebSockets. 512 is ~5x `MAX_SSE_CLIENTS` (100, the pool this one is modelled on), so
* the knob stays useful for a busy orchestration host while the whole server still fits
* inside a default fd budget with room to spare.
*/
const HARD_MAX_WAITERS_TOTAL = 512;
/** Concurrent waiters allowed across every session in the process. */
export const MAX_WAITERS_TOTAL = envInt('CODEMAN_WAIT_MAX_TOTAL', 128, 1, HARD_MAX_WAITERS_TOTAL);
/**
* Concurrent waiters allowed for one owner (multi-user mode's `Session.owner`).
*
* Sits between the per-session cap (16) and the process-wide one (128): high enough
* that one user orchestrating several workers at once never trips it, low enough that
* a single user cannot occupy the whole pool and deny the primitive to everyone else,
* admin included. Ignored entirely when the caller has no owner, which is every
* request in single-user mode.
*/
export const MAX_WAITERS_PER_OWNER = envInt('CODEMAN_WAIT_MAX_PER_OWNER', 48, 1, HARD_MAX_WAITERS_TOTAL);
/** Bounds on the literal `match` string accepted by wait-output. */
export const MIN_MATCH_LENGTH = 1;
export const MAX_MATCH_LENGTH = 200;
/**
* Tail of the terminal buffer scanned by `wait-output?from=buffer`.
*
* The buffer itself runs to 32MB. Scanning all of it would be an ANSI strip over
* 32MB (a full second copy) on a request an agent may issue in a loop, and the
* question `from=buffer` answers is "did this appear recently", not "ever". The
* tail is continuous with the live stream, since `_terminalBuffer.append(data)`
* and `emit('terminal', data)` receive the same bytes.
*/
export const MAX_BUFFER_SCAN_BYTES = envInt('CODEMAN_WAIT_BUFFER_SCAN_BYTES', 256 * 1024, 4 * 1024, 8 * 1024 * 1024);
/** Characters of surrounding output returned either side of a wait-output match. */
export const MAX_SNIPPET_CONTEXT = 80;
/**
* Clamp a caller-supplied timeout into [MIN_WAIT_MS, MAX_WAIT_MS].
* Absent / non-numeric / non-finite input falls back to DEFAULT_WAIT_MS.
*/
export function clampWaitMs(value: unknown): number {
const n = typeof value === 'string' ? Number(value) : value;
if (typeof n !== 'number' || !Number.isFinite(n)) return DEFAULT_WAIT_MS;
return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, Math.trunc(n)));
}
+14 -4
View File
@@ -173,7 +173,11 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
const curlCmd = (event: HookEventType) => const curlCmd = (event: HookEventType) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` + `HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` + `printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
`curl -s -X POST "$CODEMAN_API_URL/api/hook-event" ` + // `-k`, same as the statusline exporter: CODEMAN_API_URL is loopback HTTPS with
// a self-signed cert on --https/tailscale installs. Without it curl exits 60,
// the `|| true` swallows it, and ALL SIX hook events die silently: respawn loses
// its definitive idle signals and the wait endpoints lose stop/blocked.
`curl -sk -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` + `-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` + `-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` + `--data @- ` +
@@ -433,8 +437,10 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
* X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their * X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their
* settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the * settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the
* gate requires it unconditionally (COD-91), silently 401 on a password-protected install. * gate requires it unconditionally (COD-91), silently 401 on a password-protected install.
* Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either * Older Codeman blocks also lack the background Bash async-rewake hook. A third stale
* stale shape on launch so existing cases gain both current behaviors. * shape: hook curls without `-k`, which exit 60 on every --https/tailscale install (the
* cert is self-signed), swallowed by the hooks' own `|| true` — all six hook events die
* silently. Refresh any of these stale shapes on launch so existing cases heal.
* *
* Deliberately surgical: regenerates ONLY when settings.local.json already contains * Deliberately surgical: regenerates ONLY when settings.local.json already contains
* Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op * Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op
@@ -457,7 +463,11 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
// absence on our own hooks means they predate COD-54 and need regenerating. // absence on our own hooks means they predate COD-54 and need regenerating.
const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret'); const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret');
const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER); const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER);
if (!isOurs || (hasSecret && hasBackgroundWake)) return; // The pre--k curl shape: `curl -sk -X POST` does not contain `curl -s -X POST`
// as a substring, so this cleanly identifies hook curls that die with exit 60
// on a self-signed HTTPS install.
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
if (!isOurs || (hasSecret && hasBackgroundWake && !hasTlsFlaglessCurl)) return;
const generated = generateHooksConfig(); const generated = generateHooksConfig();
const merged = { const merged = {
...existing, ...existing,
+6 -2
View File
@@ -121,7 +121,10 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
// Inform Claude it's running within Codeman (helps prevent self-termination) // Inform Claude it's running within Codeman (helps prevent self-termination)
CODEMAN_MUX: '1', CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId, CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', // CODEMAN_API_URL rides in via the process.env spread when the server has
// stamped it (WebServer.start()); no fallback: a hardcoded one was the wrong
// scheme on HTTPS installs, and a present-with-undefined key would serialize
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
// Path only (not the secret value) — hook curls cat it at execution time (COD-54) // Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'), CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
}; };
@@ -179,7 +182,8 @@ export function buildShellEnv(sessionId: string): Record<string, string | undefi
TERM: 'xterm-256color', TERM: 'xterm-256color',
CODEMAN_MUX: '1', CODEMAN_MUX: '1',
CODEMAN_SESSION_ID: sessionId, CODEMAN_SESSION_ID: sessionId,
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000', // CODEMAN_API_URL rides in via the process.env spread when set; no fallback
// (same reasoning as buildClaudeEnv above).
// Path only (not the secret value) — hook curls cat it at execution time (COD-54) // Path only (not the secret value) — hook curls cat it at execution time (COD-54)
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'), CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
}; };
+5 -1
View File
@@ -1589,7 +1589,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
'export CODEMAN_MUX=1', 'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`, `export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`, `export CODEMAN_MUX_NAME=${muxName}`,
`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`, // Only exported when the server has stamped the real URL (scheme+host+port,
// set in WebServer.start()). A hardcoded fallback here exported the wrong
// scheme on HTTPS installs; leaving the variable unset makes in-session
// guards fail closed instead of curling a URL that was never right.
...(process.env.CODEMAN_API_URL ? [`export CODEMAN_API_URL=${process.env.CODEMAN_API_URL}`] : []),
// Path only (not the secret value): hook curl commands cat the file at // Path only (not the secret value): hook curl commands cat the file at
// execution time, so the COD-54 hook secret stays off the command line. // execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`, `export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
+22
View File
@@ -10,6 +10,7 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js'; import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js'; import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js'; import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
export function registerHookEventRoutes( export function registerHookEventRoutes(
@@ -22,6 +23,27 @@ export function registerHookEventRoutes(
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
} }
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
// and can flap mid-turn), so these two are what an orchestrating agent should
// wait on.
//
// Gated on the session's MODE, matching `resolveWaitSignals` on the read side.
// Without it the guard is one-sided: a caller cannot ASK for `stop` on a shell or
// codex session, but this endpoint would happily deliver one for it. Hook events
// carry no identity beyond a per-instance secret shared by every case, so this is
// also the cheap half of the forgery surface — a `stop` claimed for a session that
// could never legitimately emit one is now dropped instead of steering another
// agent's control flow.
const waitSession = ctx.sessions.get(sessionId);
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
if (event === 'stop') {
sessionWaits.notifySignal(sessionId, 'stop');
} else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
sessionWaits.notifySignal(sessionId, 'blocked');
}
}
// Signal the respawn controller based on hook event type // Signal the respawn controller based on hook event type
const controller = ctx.respawnControllers.get(sessionId); const controller = ctx.respawnControllers.get(sessionId);
if (controller) { if (controller) {
+530 -16
View File
@@ -4,7 +4,8 @@
* auto-clear, auto-compact, image watcher, flicker filter, and logout. * auto-clear, auto-compact, image watcher, flicker filter, and logout.
*/ */
import { FastifyInstance } from 'fastify'; import { FastifyInstance, type FastifyReply } from 'fastify';
import { z } from 'zod';
import { join, dirname, extname, basename } from 'node:path'; import { join, dirname, extname, basename } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
@@ -17,6 +18,7 @@ import {
getErrorMessage, getErrorMessage,
type ApiResponse, type ApiResponse,
type SessionColor, type SessionColor,
type SessionStatus,
type CodexConfig, type CodexConfig,
type GeminiConfig, type GeminiConfig,
type AntigravityConfig, type AntigravityConfig,
@@ -40,8 +42,19 @@ import {
QuickStartSchema, QuickStartSchema,
InteractiveStartSchema, InteractiveStartSchema,
SessionOrderUpdateSchema, SessionOrderUpdateSchema,
SessionWaitQuerySchema,
SessionWaitOutputQuerySchema,
} from '../schemas.js'; } from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js'; import { mergeSessionOrder } from '../../session-order.js';
import {
sessionWaits,
resolveWaitSignals,
signalForStatus,
WaitCapacityError,
type WaitSignal,
type SignalWaitResult,
} from '../session-wait-registry.js';
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
import { import {
autoConfigureRalph, autoConfigureRalph,
canAccessOwned, canAccessOwned,
@@ -314,6 +327,234 @@ async function clampExternalCliBypassForOwner(
return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity }; return { codexConfig: clampedCodex, geminiConfig: clampedGemini, antigravityConfig: clampedAntigravity };
} }
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
/**
* Validate a wait query WITHOUT throwing away the Zod issue.
*
* `parseBody`'s message argument REPLACES the issue text, so `?timeout=30s` came
* back as a bare "Invalid wait parameters": the caller could not tell which of
* `until`, `timeout` or `fresh` it got wrong, and its only move was to retry with
* a different guess. These endpoints are driven by an LLM with no documentation in
* context — the error message IS the documentation, which is why the signal parser
* one line later goes to the trouble of naming the bad token and listing the valid
* ones. This keeps the endpoint label AND names the offending field.
*/
function parseWaitQuery<T>(schema: z.ZodType<T>, query: unknown, label: string): T {
const result = schema.safeParse(query);
if (result.success) return result.data;
const issue = result.error.issues[0];
const field = issue && issue.path.length > 0 ? issue.path.join('.') : '';
const detail = issue?.message ?? 'validation failed';
const message = field ? `Invalid ${label} parameter '${field}': ${detail}` : `Invalid ${label} parameters: ${detail}`;
throw Object.assign(new Error(message), {
statusCode: 400,
body: createErrorResponse(ApiErrorCode.INVALID_INPUT, message),
});
}
/**
* Map a waiter-cap rejection to the code that tells the caller the truth.
*
* The two caps mean different things and warrant different recovery: `session` is
* genuinely about THIS session, while `owner` and `total` are process-wide budgets
* that say nothing about it. Reporting a global cap as `SESSION_BUSY` (409,
* documented as "Session is busy") sent an agent off to a different session to hit
* the identical error. `RATE_LIMITED` is the code whose whole meaning is "come back
* later", and clients and proxies already treat 429 that way.
*/
function waitCapacityResponse(err: WaitCapacityError): ApiResponse<never> {
const code = err.scope === 'session' ? ApiErrorCode.SESSION_BUSY : ApiErrorCode.RATE_LIMITED;
// The registry's message already names the scope and the limit; passing it through
// verbatim keeps the wording in one place.
return createErrorResponse(code, err.message);
}
/**
* The signal a session is ALREADY emitting, corrected for liveness.
*
* `signalForStatus` alone is not enough here, because `Session` parks a DEAD PTY at
* `_status = 'idle'` (both `onExit` handlers do) and the object survives in the
* session map until an explicit DELETE. Trusting the status therefore answers the
* default wait with `{signal:"idle", immediate:true}` for a worker that has
* crashed — HTTP 200, no error anywhere, and the agent types its next prompt into a
* corpse — while `until=exit` blocks for the full timeout on an event that already
* happened and can never happen again.
*
* `pid === null` means no process is behind this session: it exited, it was
* detached, or it was created and never started. All three are `exit` from a
* caller's point of view — nothing is running — and in all three the agent's
* correct next move is to (re)start the worker rather than to type at it. The
* response still carries the raw `status` alongside, so nothing is hidden.
*
* ⚠️ `pid` alone is NOT enough, and on the normal configuration it is never the
* thing that fires — see `workerIsDead()`. `dead` carries the mux layer's answer.
*
* Fixing it HERE rather than in `signalForStatus` is deliberate: liveness is not
* derivable from `SessionStatus`, and the registry holds no `Session` reference.
*/
function currentSignalFor(session: { pid: number | null; status: SessionStatus }, dead: boolean): WaitSignal | null {
if (dead || session.pid === null || session.pid === undefined) return 'exit';
return signalForStatus(session.status);
}
// ── Worker liveness for tmux-backed sessions ────────────────────────────────
//
// `session.pid` is the LOCAL `tmux attach` client, not the worker. Codeman sets
// `remain-on-exit on` for every session it creates, so when the command inside the
// pane exits, tmux keeps the pane (`pane_dead=1`), the tmux session survives, the
// attach client keeps running and `pid` never goes null — no `exit` event is emitted
// and nothing in `Session` changes. Measured on a shell worker killed with `exit 42`:
// tmux reports `pane_dead=1 status=42` while Codeman reports `pid=309406 status=idle`
// and the DEFAULT wait answers `{signal:"idle", immediate:true}` in 0 ms for a corpse.
// So the liveness check has to ask the mux layer. `pid === null` still matters: it is
// the right (and only) answer for a direct-PTY session, which has no pane to ask about.
//
// Cost control, because `isPaneDead()` is a synchronous `execSync` and `/wait` is
// polled in a loop by design:
// 1. Only mux-backed sessions are probed at all.
// 2. Only requests that actually wait probe — a plain `POST .../input` (the browser's
// hot path, thousands per session) never touches tmux.
// 3. Results are cached per pane for PANE_DEATH_TTL_MS, so a poll loop cannot turn
// into one exec per request.
// 4. The while-blocked watcher is ONE timer per session no matter how many waiters
// are parked on it, and it exists only while at least one of them is.
/** How long a pane-liveness probe is reused. Long enough to absorb a poll loop. */
const PANE_DEATH_TTL_MS = 750;
/** How often a session with a parked waiter is re-checked for a dead worker. */
const PANE_DEATH_POLL_MS = 3_000;
/** Bounded, because a 24h server churns through panes. */
const paneDeathCache = new LRUMap<string, { dead: boolean; at: number }>({ maxSize: 256 });
/** One watcher per pane, refcounted by the waits currently parked on it. */
const paneDeathWatchers = new Map<string, { timer: NodeJS.Timeout; refs: number }>();
type LivenessSession = { usesMux?: boolean; muxName?: string | null };
/**
* Whether the worker inside this session's tmux pane has exited.
*
* False for anything not tmux-backed (nothing to ask), and false when the probe is
* unavailable or throws — an unknown answer must never invent a death.
*/
function workerIsDead(mux: InfraPort['mux'], session: LivenessSession, now: number = Date.now()): boolean {
const muxName = session.usesMux === false ? null : session.muxName;
if (!muxName) return false;
// Defensive: `TerminalMultiplexer` declares it, but route-test doubles may not.
if (typeof mux?.isPaneDead !== 'function') return false;
const cached = paneDeathCache.get(muxName);
if (cached && now - cached.at < PANE_DEATH_TTL_MS) return cached.dead;
let dead = false;
try {
dead = mux.isPaneDead(muxName) === true;
} catch {
dead = false;
}
paneDeathCache.set(muxName, { dead, at: now });
return dead;
}
/**
* Release every waiter on a session whose worker has died, in the documented order.
*
* The same pair the PTY-exit listener and the delete path use, for the same reason:
* `until=exit` callers get their signal, everyone else gets `ended: true` instead of
* burning the rest of their timeout on feeds that will never produce anything.
*/
function releaseWaitersForDeadWorker(sessionId: string): void {
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
}
/**
* While a wait is parked on a mux-backed session, poll for the worker dying.
*
* Without this, a worker that dies DURING a wait is invisible: no `exit` event fires
* (the attach client is still alive), no output arrives, and the caller blocks for its
* full timeout — the common orchestration case, "send a prompt and wait", where the
* worker crashes mid-turn.
*
* @returns a release function; call it in a `finally`, or the timer outlives the wait.
*/
function watchForDeadWorker(mux: InfraPort['mux'], session: LivenessSession, sessionId: string): () => void {
const muxName = session.usesMux === false ? null : session.muxName;
if (!muxName || typeof mux?.isPaneDead !== 'function') return () => {};
const existing = paneDeathWatchers.get(muxName);
if (existing) {
existing.refs++;
} else {
const timer = setInterval(() => {
if (!workerIsDead(mux, session)) return;
releaseWaitersForDeadWorker(sessionId);
}, PANE_DEATH_POLL_MS);
// Auxiliary to the waiter's own timer, which is deliberately NOT unref'd; this one
// must never be the reason the process stays up.
timer.unref();
paneDeathWatchers.set(muxName, { timer, refs: 1 });
}
let released = false;
return () => {
if (released) return;
released = true;
const entry = paneDeathWatchers.get(muxName);
if (!entry) return;
entry.refs--;
if (entry.refs <= 0) {
clearInterval(entry.timer);
paneDeathWatchers.delete(muxName);
}
};
}
/** Test seam: pane-liveness state is module-level, so a suite must be able to reset it. */
export function _resetPaneLivenessState(): void {
for (const entry of paneDeathWatchers.values()) clearInterval(entry.timer);
paneDeathWatchers.clear();
paneDeathCache.clear();
}
/** Test seam: how many panes are currently being watched for a dead worker. */
export function _paneDeathWatcherCount(): number {
return paneDeathWatchers.size;
}
/**
* An `AbortController` that fires when the CLIENT goes away, and only then.
*
* Freeing an abandoned waiter matters because the documented pattern is a loop of
* short waits: `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter
* every iteration until the cap is hit and an innocent session reports busy. Same for
* any proxy that cuts the connection.
*
* ⚠️ **It must listen on the RESPONSE, not the request.** `req.raw` emits `'close'`
* as soon as the request body has finished streaming, which on a POST is BEFORE the
* handler ever blocks — measured at +1ms with `aborted: false`, indistinguishable
* from a real hang-up at +0ms. Wiring the abort there cancels every send-and-wait
* instantly and silently kills the feature (it survives on GET only because a GET has
* no body to finish). `reply.raw` emits `'close'` both when the response completes
* and when the socket dies, and `writableFinished` is what tells those apart: true
* only if the response actually went out. The guard is load-bearing, not defensive.
*
* `app.inject()` never emits `'close'` at all, so this is only observable over real
* HTTP — which is why the regression test for it binds a port.
*/
function abortOnClientHangUp(reply: FastifyReply): AbortController {
const controller = new AbortController();
reply.raw.on('close', () => {
if (!reply.raw.writableFinished) controller.abort();
});
return controller;
}
export function registerSessionRoutes( export function registerSessionRoutes(
app: FastifyInstance, app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -863,9 +1104,9 @@ export function registerSessionRoutes(
// ========== Send Input ========== // ========== Send Input ==========
app.post('/api/sessions/:id/input', async (req) => { app.post('/api/sessions/:id/input', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body); const { input, useMux, seq, clientId, wait, waitTimeout } = parseBody(SessionInputWithLimitSchema, req.body);
const session = findSessionOrFail(ctx, id, req); const session = findSessionOrFail(ctx, id, req);
const inputStr = String(input); const inputStr = String(input);
@@ -876,27 +1117,102 @@ export function registerSessionRoutes(
); );
} }
// Send-and-wait (agent orchestration). This has to be ONE endpoint rather than a
// POST followed by GET .../wait: between the write and the session flipping to
// `working` there is a window in which a separate wait sees the session still
// idle and returns instantly, reporting the PREVIOUS turn as this turn's answer.
// Registering the waiter before the write closes that window.
const wantsWait =
wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
let until: readonly WaitSignal[] = [];
if (wantsWait) {
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
until = resolved.until;
}
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the // Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
// client's ACK, so a tagged duplicate redelivery must still return 200 but // client's ACK, so a tagged duplicate redelivery must still return 200 but
// skip the write. Untagged requests (curl/legacy) always apply. // skip the write. Untagged requests (curl/legacy) always apply.
const tagged = typeof clientId === 'string' && typeof seq === 'number'; const tagged = typeof clientId === 'string' && typeof seq === 'number';
if (tagged && !session.shouldApplyInput(clientId as string, seq as number)) { const duplicate = tagged && !session.shouldApplyInput(clientId as string, seq as number);
if (duplicate && !wantsWait) {
return {}; return {};
} }
// Only a waiting request pays for the tmux probe: the browser's plain input path
// (thousands of calls per session) must stay exec-free.
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
const timeoutMs = clampWaitMs(waitTimeout ?? undefined);
// Same slot leak as the GET routes: a client that gives up mid-wait would
// otherwise hold a waiter for the full timeout. Response-side, always — see
// abortOnClientHangUp: on THIS route a request-side listener fires the moment the
// JSON body finishes streaming and aborts every send-and-wait before it starts.
const abort = abortOnClientHangUp(reply);
let waitPromise: Promise<SignalWaitResult> | null = null;
if (wantsWait) {
try {
waitPromise = sessionWaits.waitForSignal(id, {
until,
timeoutMs,
owner: ownerFor(req),
abortSignal: abort.signal,
// A FRESH delivery must not be satisfied by the state the session is already
// in: it is idle right now, which is precisely why we are typing at it.
// A DUPLICATE has no new turn coming, so it answers from the current state
// instead of blocking for a transition that already happened.
requireTransition: !duplicate,
currentSignal: duplicate ? currentSignalFor(session, workerDead) : undefined,
});
} catch (err) {
if (err instanceof WaitCapacityError) {
// Nothing has been written yet, but `shouldApplyInput` already consumed the
// seq. Give it back or the caller's retry is rejected as a duplicate and the
// input is lost by the very mechanism meant to make delivery reliable.
if (tagged && !duplicate) session.forgetInputSeq(clientId as string, seq as number);
return waitCapacityResponse(err);
}
throw err;
}
}
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
// Write input to PTY. Direct write is synchronous; writeViaMux // Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response. // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useMux) { //
// Because the response has already been sent by then, a failure there is the
// one case the caller can never learn about — so the dedup bookkeeping is
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
// very mechanism reliable delivery exists for, is rejected as a duplicate.
const undoOnFailure = () => {
if (tagged) session.forgetInputSeq(clientId as string, seq as number);
};
// Whether the bytes actually reached a write path. Only meaningful on the wait
// path (the fire-and-forget branches return before the response is built), and
// reported there instead of the old `!duplicate`: a PTY that has exited fails
// BOTH writes, and telling the caller "delivered, but it timed out" points it at
// the wrong recovery — wait longer, when the truth is "restart the worker".
let delivered = false;
if (duplicate) {
// Redelivery of an already-applied input: skip the write, but still honor the
// wait, since the caller's question ("tell me when this settles") is unanswered.
} else if (useMux && waitPromise) {
// The response is already staying open for the wait, so the tmux write can be
// awaited here. This is the ONE path where a writeViaMux failure is observable.
const ok = await session.writeViaMux(inputStr).catch(() => false);
if (ok) {
delivered = true;
} else {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
delivered = session.write(inputStr);
if (!delivered) undoOnFailure();
}
} else if (useMux) {
// Fire-and-forget: don't block the HTTP response on a tmux child process. // Fire-and-forget: don't block the HTTP response on a tmux child process.
// Fallback to a direct write on failure. // Fallback to a direct write on failure. Unchanged from before send-and-wait.
//
// Because the response has already been sent by then, a failure here is the
// one case the caller can never learn about — so the dedup bookkeeping is
// rolled back. Otherwise the seq stays recorded as applied and a retry, the
// very mechanism reliable delivery exists for, is rejected as a duplicate.
const undoOnFailure = () => {
if (tagged) session.forgetInputSeq(clientId as string, seq as number);
};
session session
.writeViaMux(inputStr) .writeViaMux(inputStr)
.then((ok) => { .then((ok) => {
@@ -911,11 +1227,209 @@ export function registerSessionRoutes(
// Same rollback. NOT an error response, deliberately: a session can // Same rollback. NOT an error response, deliberately: a session can
// legitimately have no PTY yet (created but not started), and callers have // legitimately have no PTY yet (created but not started), and callers have
// always been able to write to one without a 4xx. // always been able to write to one without a 4xx.
if (!session.write(inputStr) && tagged) { delivered = session.write(inputStr);
if (!delivered && tagged) {
session.forgetInputSeq(clientId as string, seq as number); session.forgetInputSeq(clientId as string, seq as number);
} }
} }
return {};
if (!waitPromise) return {};
try {
// `send-keys` SUCCEEDS against a dead pane — tmux is happy to write into a corpse
// — so a truthful `delivered` cannot come from the write's return value alone.
// This is the case the field exists for: "delivered, but it timed out" tells an
// agent to wait longer when the truth is "restart the worker".
if (delivered && workerDead) {
delivered = false;
// The bytes went nowhere, so the seq must not be recorded as applied or the
// caller's retry against a restarted worker is refused as a duplicate.
if (!duplicate) undoOnFailure();
}
// Nothing was written and nothing will be: no turn is coming, so blocking for the
// full timeout would only delay the caller's real recovery by up to ten minutes.
// Releasing the waiter also hands its slot back immediately.
const selfReleased = !delivered && !duplicate;
if (selfReleased) abort.abort();
const result = await waitPromise;
return {
success: true,
data: {
delivered,
duplicate,
status: session.status,
limitPaused: session.isLimitPaused,
// Identical `wait` object to the two GET endpoints, so one client helper
// reads all three, `timeoutMs` (post-clamp) included.
//
// `aborted` is the CLIENT-facing "you hung up, nobody is reading this", and
// by that definition it is unobservable — which is exactly what the API
// reference promises. The abort above is the server releasing its own waiter
// on a delivery that failed, and the client IS reading this response, so
// reporting `aborted: true` there would break that promise and hand an agent
// a second, contradictory reason for the same outcome. `delivered: false`
// already says what happened; `ended` says the wait was released early.
wait: { ...result, aborted: selfReleased ? false : result.aborted, until: [...until] },
},
};
} finally {
stopDeathWatch();
}
});
// ========== Wait For A Signal (agent orchestration) ==========
//
// A bounded long-poll: block until the session hits one of `until`, then answer.
// This exists because SSE is the only "tell me when" channel Codeman has, and an
// agent driving the API from a shell tool cannot hold a stream and parse events
// inline. See docs/agent-control-plan.md.
//
// A TIMEOUT IS A 200, not an error: callers are expected to loop over short waits
// (proxies such as `tailscale serve` cut idle connections), and turning every poll
// boundary into a 4xx would make that loop indistinguishable from a real failure.
app.get('/api/sessions/:id/wait', async (req, reply) => {
const { id } = req.params as { id: string };
const query = parseWaitQuery(SessionWaitQuerySchema, req.query, 'wait');
const session = findSessionOrFail(ctx, id, req);
// An agent polls this URL in a loop with identical parameters. Any intermediary
// applying heuristic freshness to the 200 would serve the stored `timedOut:true`
// body to the next iteration instantly, turning the loop into a busy spin that
// never observes the signal.
reply.header('Cache-Control', 'no-store');
// Shared with the `wait` field on POST .../input: unknown token is a 400,
// hook-only signals are rejected explicitly but dropped from the default.
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
// The value actually applied after clamping, echoed below: a caller that asked
// for 30 minutes and silently got 10 could not otherwise tell a poll boundary
// from a wedged worker, and would kill a session that was working fine.
const timeoutMs = clampWaitMs(query.timeout);
// Free the waiter when the caller hangs up; the response can no longer be sent by
// then, so freeing the slot is the entire purpose.
const abort = abortOnClientHangUp(reply);
// A worker that dies while this request is parked emits nothing at all (the tmux
// attach client survives it), so a wait would otherwise run to its full timeout.
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
try {
const result = await sessionWaits.waitForSignal(id, {
until,
timeoutMs,
owner: ownerFor(req),
abortSignal: abort.signal,
requireTransition: query.fresh === '1' || query.fresh === 'true',
// Read BEFORE awaiting: this is the state the caller is asking about.
currentSignal: currentSignalFor(session, workerIsDead(ctx.mux, session)),
});
return {
success: true,
data: {
sessionId: id,
// Post-wait status, so a caller that timed out still learns where things stand.
status: session.status,
// A session paused on a usage limit emits nothing until its reset, so a
// timeout here is expected rather than a stall worth retrying hard.
limitPaused: session.isLimitPaused,
// One shape across all three endpoints, so a single `is_done(resp)` helper
// works against any of them. `result.timeoutMs` is the value actually
// applied after clamping, which is what makes the clamp observable.
wait: { ...result, until: [...until] },
},
};
} catch (err) {
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
throw err;
} finally {
stopDeathWatch();
}
});
// ========== Wait For Output (agent orchestration) ==========
//
// The companion to /wait: block until a literal string appears in this session's
// output. Same 200-on-timeout contract. Fed by the `terminal` listener in
// session-listener-wiring.ts, so what this scans is byte-for-byte what the pane
// printed, ANSI stripped.
//
// ⚠️ A tmux repaint replays text already on screen, so `from=now` can match
// something printed before the request. Callers need a marker unique per call.
app.get('/api/sessions/:id/wait-output', async (req, reply) => {
const { id } = req.params as { id: string };
// Reject `regex` loudly instead of ignoring it. Matching is deliberately literal
// (no ReDoS surface on a caller-supplied pattern over a live stream), and an agent
// that assumed otherwise would silently wait on the wrong thing.
if (req.query && typeof req.query === 'object' && 'regex' in req.query) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'regex is not supported; use match=<literal substring> (optionally with nocase=1)'
);
}
const query = parseWaitQuery(SessionWaitOutputQuerySchema, req.query, 'wait-output');
const session = findSessionOrFail(ctx, id, req);
// Same reason as /wait: this URL is polled in a loop with identical parameters.
reply.header('Cache-Control', 'no-store');
const timeoutMs = clampWaitMs(query.timeout);
const abort = abortOnClientHangUp(reply);
const owner = ownerFor(req);
// Output waiters are the ones a dead worker strands hardest: the feed simply stops.
const stopDeathWatch = watchForDeadWorker(ctx.mux, session, id);
try {
// Check the cap BEFORE touching the buffer. `session.terminalBuffer` is
// `BufferAccumulator.value`, which joins the WHOLE accumulator (up to 32MB)
// before the slice below takes its tail — so a request that is going to be
// rejected anyway must not pay for a full materialization first, or the cap
// provides no backpressure at all against a `from=buffer` loop.
sessionWaits.assertCapacity(id, owner);
// `from=buffer` scans what already scrolled past before blocking. Bounded to a
// tail: the buffer runs to 32MB and this is a per-request ANSI strip.
let initialText: string | undefined;
if (query.from === 'buffer') {
const buffer = session.terminalBuffer;
initialText =
buffer.length > MAX_BUFFER_SCAN_BYTES ? buffer.slice(buffer.length - MAX_BUFFER_SCAN_BYTES) : buffer;
}
const result = await sessionWaits.waitForOutput(id, {
match: query.match,
nocase: query.nocase === '1' || query.nocase === 'true',
timeoutMs,
owner,
abortSignal: abort.signal,
initialText,
});
return {
success: true,
data: {
sessionId: id,
status: session.status,
limitPaused: session.isLimitPaused,
// Same envelope as /wait; this one carries `matched`/`snippet`/`match`
// where the signal wait carries `signal`/`until`.
wait: { ...result, match: query.match },
},
};
} catch (err) {
if (err instanceof WaitCapacityError) return waitCapacityResponse(err);
throw err;
} finally {
stopDeathWatch();
}
}); });
// ========== Send Named Key (tmux send-keys -H) ========== // ========== Send Named Key (tmux send-keys -H) ==========
+61
View File
@@ -17,6 +17,7 @@ import {
MIN_TERMINAL_SCROLLBACK_LINES, MIN_TERMINAL_SCROLLBACK_LINES,
} from '../config/terminal-history.js'; } from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
// ========== Path Validation ========== // ========== Path Validation ==========
@@ -911,6 +912,66 @@ export const SessionInputWithLimitSchema = z.object({
// unset rather than sending null. See docs/reliable-input-delivery.md. // unset rather than sending null. See docs/reliable-input-delivery.md.
seq: z.number().int().nonnegative().optional(), seq: z.number().int().nonnegative().optional(),
clientId: z.string().max(128).optional(), clientId: z.string().max(128).optional(),
// Send-and-wait (agent orchestration): `true` for the default signal set, or the
// same grammar as `GET .../wait` — a comma string or an array of signals. Absent
// means the historical fire-and-forget behavior, byte for byte.
//
// `.nullish()`, not `.optional()`: a third-party caller building the body with
// JSON.stringify keeps an explicit null on the wire, and `.optional()` rejects it
// with INVALID_INPUT. That gotcha has shipped as a real bug twice.
wait: z.union([z.boolean(), z.string().max(120), z.array(z.string().max(120)).max(8)]).nullish(),
// Unbounded above: the effective value is clamped to MAX_WAIT_MS server-side and
// returned as `data.wait.timeoutMs`, so a caller that asks for 24h sees what it
// actually got. A `.max()` here would turn the same documented clamp into a 400 for
// large-enough guesses, which is the one behaviour an agent cannot predict.
waitTimeout: z.number().int().positive().nullish(),
});
/**
* Query validation for `GET /api/sessions/:id/wait` (agent wait primitives).
*
* Everything arrives as a string. `timeout` is coerced and bounded here, then
* clamped again to the operator's ceiling by `clampWaitMs()` — the schema bound
* only keeps an absurd number out of the arithmetic. A non-numeric `timeout` is a
* 400 rather than a silent fallback, so an agent never believes it asked for a
* longer wait than it got; the value actually applied comes back as
* `data.wait.timeoutMs`, which is what makes the clamp observable. `until` is
* parsed by `parseWaitSignals()`, which reports unknown tokens instead of
* dropping them.
*
* `until` accepts an ARRAY as well as the comma string: `?until=stop&until=exit`
* is how most HTTP clients express a list, Fastify's query parser delivers a
* repeated parameter as an array, and `parseWaitSignals()` has always handled
* both. Rejecting the repeated form left that branch unreachable and 400'd the
* more natural spelling.
*/
export const SessionWaitQuerySchema = z.object({
until: z.union([z.string().max(120), z.array(z.string().max(120)).max(8)]).optional(),
// No upper bound on purpose. The contract is "clamped to [MIN_WAIT_MS, MAX_WAIT_MS]",
// and a `.max()` here contradicted it: `timeout=99999999` was a 400 mid-fan-out while
// `timeout=600001` was silently clamped, so the same documented rule produced two
// different outcomes depending on how big the caller's guess was. `clampWaitMs()`
// bounds every finite value, and `.int()` still rejects `Infinity`/`1e999` and junk.
timeout: z.coerce.number().int().positive().optional(),
fresh: z.enum(['0', '1', 'true', 'false']).optional(),
});
/**
* Query validation for `GET /api/sessions/:id/wait-output`.
*
* `match` is a LITERAL substring, never a pattern: `search-service.ts` avoids regex
* so there is no ReDoS surface, and this endpoint is more exposed still (the pattern
* would be caller-supplied and the input is a live stream). The length bound is a
* second reason the carry buffer stays small. The route separately rejects a `regex`
* parameter outright rather than ignoring it.
*/
export const SessionWaitOutputQuerySchema = z.object({
match: z.string().min(MIN_MATCH_LENGTH).max(MAX_MATCH_LENGTH),
nocase: z.enum(['0', '1', 'true', 'false']).optional(),
from: z.enum(['now', 'buffer']).optional(),
// Unbounded above for the same reason as SessionWaitQuerySchema.timeout: clamping is
// the documented contract, so a large value must clamp rather than 400.
timeout: z.coerce.number().int().positive().optional(),
}); });
// ========== Session Mutation Routes ========== // ========== Session Mutation Routes ==========
+16
View File
@@ -85,6 +85,7 @@ import {
attachSessionListeners, attachSessionListeners,
detachSessionListeners, detachSessionListeners,
} from './session-listener-wiring.js'; } from './session-listener-wiring.js';
import { sessionWaits } from './session-wait-registry.js';
import { import {
wireRespawnListeners, wireRespawnListeners,
setupTimedRespawn, setupTimedRespawn,
@@ -1247,6 +1248,16 @@ export class WebServer extends EventEmitter {
} }
} }
// Release anything blocked on this session, in the documented order: 'exit'
// first so an until=exit caller gets its signal, then cancelAll so everyone
// else resolves with ended:true instead of timing out.
//
// The 'exit' here is NOT redundant with the PTY-exit listener: listeners are
// detached a few lines above, before `session.stop()`, so on a delete the
// session's own exit event never reaches the registry.
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
} }
@@ -2845,6 +2856,11 @@ export class WebServer extends EventEmitter {
// Gracefully close all SSE connections and clear batching state // Gracefully close all SSE connections and clear batching state
this.sse.stop(); this.sse.stop();
// Release every pending long-poll waiter. Their timers are deliberately not
// unref'd (an unref'd timer can let the process exit mid-wait and strand the
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
this.lastRecordedTokens.clear(); this.lastRecordedTokens.clear();
// Stop multiplexer and flush pending saves // Stop multiplexer and flush pending saves
+28
View File
@@ -27,6 +27,7 @@ import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js';
import { SseEvent } from './sse-events.js'; import { SseEvent } from './sse-events.js';
import { getLifecycleLog } from '../session-lifecycle-log.js'; import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js'; import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
/** Stored listener references for session cleanup (prevents memory leaks) */ /** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs { export interface SessionListenerRefs {
@@ -92,6 +93,9 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */ /** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */
terminal: (data) => { terminal: (data) => {
// Feeds `GET /api/sessions/:id/wait-output`. No-ops with a single Map lookup
// when nothing is waiting, which is the case on virtually every chunk.
sessionWaits.notifyOutput(session.id, data);
deps.batchTerminalData(session.id, data); deps.batchTerminalData(session.id, data);
}, },
@@ -137,6 +141,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */ /** Broadcasts `session:exit` + `session:updated` — PTY process exited; cleans up respawn, timers, listeners */
exit: (code) => { exit: (code) => {
// Before anything that can throw: a caller blocked on this session must learn
// the process died rather than sit until its timeout.
//
// Both halves are required, in this order — the same pair `_doCleanupSession`
// uses on the delete path, for the same reason. `notifySignal` resolves ONLY
// waiters that asked for `exit`; everyone else (`until=working`, `until=stop`,
// every wait-output) would keep a slot in the process-wide pool until their
// timeout, on a session whose feeds this very handler is about to tear down:
// `removeSessionListenerRefs` below detaches the `terminal` listener that is
// the only input to `notifyOutput`, and the `idle`/`working` listeners with it.
// Nothing can reach those waiters afterwards, so holding them is a guaranteed
// ten-minute lie. `cancelAll` answers them `ended: true`, which the plan's §3.6
// specifies for exactly this case ("Never hang").
//
// Safe against the respawn cycle: a respawn writes `/clear` + a kickstart
// prompt through the mux and never restarts the PTY, so it emits no `exit` and
// cannot cancel an orchestrating agent's wait. And for an agent driving a
// worker this is the right trade even when the PTY exit was only a tmux
// DETACH: `ended` means "re-check and re-issue", one extra round trip, versus
// burning the caller's entire timeout learning nothing.
sessionWaits.notifySignal(session.id, 'exit');
sessionWaits.cancelAll(session.id);
getLifecycleLog().log({ getLifecycleLog().log({
event: 'exit', event: 'exit',
sessionId: session.id, sessionId: session.id,
@@ -187,6 +213,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:working` — Claude started processing */ /** Broadcasts `session:working` — Claude started processing */
working: () => { working: () => {
sessionWaits.notifySignal(session.id, 'working');
deps.broadcast(SseEvent.SessionWorking, { id: session.id }); deps.broadcast(SseEvent.SessionWorking, { id: session.id });
const tracker = deps.getRunSummaryTracker(session.id); const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) { if (tracker) {
@@ -197,6 +224,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */ /** Broadcasts `session:idle` — Claude finished processing, waiting for input */
idle: () => { idle: () => {
sessionWaits.notifySignal(session.id, 'idle');
deps.broadcast(SseEvent.SessionIdle, { id: session.id }); deps.broadcast(SseEvent.SessionIdle, { id: session.id });
deps.broadcastSessionStateDebounced(session.id); deps.broadcastSessionStateDebounced(session.id);
const tracker = deps.getRunSummaryTracker(session.id); const tracker = deps.getRunSummaryTracker(session.id);
File diff suppressed because it is too large Load Diff
+19
View File
@@ -133,6 +133,25 @@ describe('refreshStaleCodemanHooks', () => {
expect(after.hooks.CustomEvent).toEqual(customEvent); expect(after.hooks.CustomEvent).toEqual(customEvent);
}); });
// A case can be current on the secret AND the background-wake hook and still carry
// the `-k`-less curl shape, which exits 60 against a self-signed HTTPS API and is
// swallowed by `|| true` — every hook event dead, silently. The refresh must treat
// that as a third stale shape.
it('heals a current-looking block whose hook curls lack -k (HTTPS self-signed installs)', async () => {
const { generateHooksConfig } = await import('../src/hooks-config.js');
const flagless = JSON.parse(JSON.stringify(generateHooksConfig()).replaceAll('curl -sk ', 'curl -s '));
writeFileSync(settingsPath, JSON.stringify({ hooks: flagless.hooks }, null, 2));
await refreshStaleCodemanHooks(dir);
const after = readFileSync(settingsPath, 'utf-8');
expect(after).toContain('curl -sk -X POST');
expect(after).not.toContain('curl -s -X POST');
// and the pass is convergent: a second refresh must not rewrite
await refreshStaleCodemanHooks(dir);
expect(readFileSync(settingsPath, 'utf-8')).toBe(after);
});
it('is a no-op when settings.local.json is absent (does not create one)', async () => { it('is a no-op when settings.local.json is absent (does not create one)', async () => {
await refreshStaleCodemanHooks(dir); await refreshStaleCodemanHooks(dir);
expect(existsSync(settingsPath)).toBe(false); expect(existsSync(settingsPath)).toBe(false);
+11
View File
@@ -95,6 +95,17 @@ describe('generateHooksConfig', () => {
expect(notifHooks[0].hooks[0].command).toContain('|| true'); expect(notifHooks[0].hooks[0].command).toContain('|| true');
}); });
// On --https/tailscale installs CODEMAN_API_URL is HTTPS with a self-signed cert.
// A `-k`-less hook curl exits 60 there, the `|| true` swallows it, and every hook
// event (stop, permission_prompt, elicitation_dialog, idle_prompt, teammate_idle,
// task_completed) dies silently — killing respawn's idle signals and the wait
// endpoints' stop/blocked. The statusline exporter always carried -k; the hooks must too.
it('every hook curl tolerates a self-signed HTTPS API (curl -sk)', () => {
const serialized = JSON.stringify(generateHooksConfig());
expect(serialized).toContain('curl -sk -X POST');
expect(serialized).not.toContain('curl -s -X POST');
});
it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => { it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => {
const config = generateHooksConfig(); const config = generateHooksConfig();
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>; const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>;
+93
View File
@@ -89,4 +89,97 @@ describe('Stable HTTP contract (live server)', () => {
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT'); expect(body.errorCode).toBe('INVALID_INPUT');
}); });
/**
* The agent wait primitives, through the REAL pipeline.
*
* Their own route tests hand-roll a partial copy of the preSerialization hook that
* maps errorCode to status but does NOT wrap bare payloads — so nothing there
* proves these routes emit a correct envelope, a correct status, or work through
* the /api/v1 alias, and one assertion in them pins `{}` for a response no client
* will ever receive. This is the file whose docstring already claims that scope.
*/
describe('agent wait primitives', () => {
let sessionId: string;
beforeAll(async () => {
const res = await fetch(`${base}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
sessionId = (await res.json()).data.session.id;
expect(sessionId).toBeDefined();
});
afterAll(async () => {
await fetch(`${base}/api/sessions/${sessionId}`, { method: 'DELETE' });
});
it('answers a wait timeout as a 200 inside the envelope, on the /api/v1 alias', async () => {
// A timeout is the long-poll SUCCEEDING at "did this happen within N ms?"; a
// 4xx/5xx here would make every poll boundary indistinguishable from a failure.
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=working&timeout=1000`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBe('no-store');
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.sessionId).toBe(sessionId);
// The one shape all three wait endpoints share.
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.signal).toBeNull();
expect(body.data.wait.timeoutMs).toBe(1000);
expect(body.data.wait.until).toEqual(['working']);
});
it('returns a contract-shaped 400 for an unknown until token', async () => {
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=stpo`);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('stpo');
});
it('returns a contract-shaped 400 naming the bad query parameter', async () => {
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?timeout=30s`);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('timeout');
});
it('wraps the non-wait input response as { success: true, data: {} }', async () => {
// What a client actually receives on the fire-and-forget path — NOT the bare
// `{}` the handler returns and the route tests assert.
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'hello' }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ success: true, data: {} });
});
it('serves wait-output through the same envelope', async () => {
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait-output?match=NEVER_APPEARS&timeout=1000`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.wait.matched).toBe(false);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.match).toBe('NEVER_APPEARS');
});
it('404s an unknown session on both new routes, with the error envelope', async () => {
for (const path of ['wait?until=idle', 'wait-output?match=x']) {
const res = await fetch(`${base}/api/v1/sessions/nonexistent/${path}`);
expect(res.status).toBe(404);
const body = await res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('NOT_FOUND');
}
});
});
}); });
+21 -3
View File
@@ -4,6 +4,7 @@
*/ */
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { SessionStatus } from '../../src/types.js';
/** /**
* Enhanced mock session for testing RespawnController. * Enhanced mock session for testing RespawnController.
@@ -12,8 +13,15 @@ import { vi } from 'vitest';
export class MockSession extends EventEmitter { export class MockSession extends EventEmitter {
id: string; id: string;
workingDir: string = '/tmp/test-workdir'; workingDir: string = '/tmp/test-workdir';
status: 'idle' | 'working' = 'idle'; /**
pid: number = 12345; * The REAL union, deliberately. This used to be `'idle' | 'working'`, and
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
* `default: null` branch in every route test and the busy / stopped / error halves
* of the immediate-resolve mapping had zero coverage while appearing to be tested.
*/
status: SessionStatus = 'idle';
/** `null` once the PTY is gone (or before it has ever started) — see `pid` in Session. */
pid: number | null = 12345;
isWorking: boolean = false; isWorking: boolean = false;
private _activeChildProcesses: { pid: number; command: string }[] = []; private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null; ralphTracker: null = null;
@@ -113,7 +121,9 @@ export class MockSession extends EventEmitter {
/** Simulate working state with spinner */ /** Simulate working state with spinner */
simulateWorking(text: string = 'Thinking'): void { simulateWorking(text: string = 'Thinking'): void {
this.simulateTerminalOutput(`${text}... \u280b`); this.simulateTerminalOutput(`${text}... \u280b`);
this.status = 'working'; // 'busy' is what the real Session sets while a turn is in flight; the old
// 'working' here was the event name, not a status value.
this.status = 'busy';
this.emit('working'); this.emit('working');
} }
@@ -182,6 +192,14 @@ export class MockSession extends EventEmitter {
return this._muxName; return this._muxName;
} }
/**
* Mirrors `Session.usesMux`. True by default because that is the normal
* configuration, and it is what makes a route's pane-liveness probe reachable:
* `session.pid` is the tmux ATTACH CLIENT, so a mux-backed session's worker can be
* dead while `pid` is still a live number.
*/
usesMux: boolean = true;
/** Check for active child processes (mock returns configurable list) */ /** Check for active child processes (mock returns configurable list) */
getActiveChildProcesses(): { pid: number; command: string }[] { getActiveChildProcesses(): { pid: number; command: string }[] {
return this._activeChildProcesses; return this._activeChildProcesses;
+671
View File
@@ -0,0 +1,671 @@
/**
* @fileoverview Route tests for the `wait` field on `POST /api/sessions/:id/input`.
*
* This endpoint exists to close a race a caller cannot close from outside: between
* the write landing and the session flipping to `working`, a SEPARATE wait sees the
* session still idle and returns instantly, reporting the previous turn as this one.
* Registering the waiter before the write is the whole point, so that is what the
* first test pins.
*
* It also pins that the historical fire-and-forget path is untouched when `wait` is
* absent, that a capacity rejection gives the dedup seq back (otherwise the caller's
* retry is refused as a duplicate and the input is lost by the very mechanism
* reliable delivery exists for), and that `delivered` reports what actually happened
* to the write rather than merely "this was not a duplicate".
*
* Plan: docs/agent-control-plan.md
*/
import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import type { IncomingMessage } from 'node:http';
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
const SESSION_ID = 'input-wait-session';
const URL = `/api/sessions/${SESSION_ID}/input`;
afterEach(() => {
// Deliberately not `cancelEverything()`: it latches the registry's stopped flag,
// which would leave every later test in this file talking to a dead registry.
sessionWaits.cancelAll(SESSION_ID);
_resetPaneLivenessState();
});
async function harness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext; rawRequests: IncomingMessage[] }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
const rawRequests: IncomingMessage[] = [];
app.addHook('onRequest', async (req) => {
rawRequests.push(req.raw);
});
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, rawRequests };
}
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: URL, payload });
describe('POST /api/sessions/:id/input without wait (unchanged behavior)', () => {
it('returns the historical bare body and registers no waiter', async () => {
const { app } = await harness();
const res = await send(app, { input: 'hello', useMux: true });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('still returns before the mux write settles', async () => {
// The fire-and-forget property is why the response is fast; send-and-wait must
// not have turned every input into an awaited tmux round-trip.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
let resolveWrite: (ok: boolean) => void = () => {};
session.writeViaMux = () => new Promise<boolean>((resolve) => (resolveWrite = resolve));
const res = await send(app, { input: 'hello', useMux: true });
expect(res.json()).toEqual({});
resolveWrite(true);
});
it('a tagged duplicate still returns the bare body', async () => {
const { app } = await harness();
await send(app, { input: 'first', clientId: 'c1', seq: 1 });
const replay = await send(app, { input: 'first', clientId: 'c1', seq: 1 });
expect(replay.json()).toEqual({});
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('a failed direct write is still not an error response', async () => {
// A session can legitimately have no PTY yet, and callers have always been able
// to write to one without a 4xx. Only the `wait` path reports delivery.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const res = await send(app, { input: 'x' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
});
});
describe('POST /api/sessions/:id/input with wait', () => {
it('registers the waiter BEFORE the write, so the pre-existing idle state cannot satisfy it', async () => {
// The mock session is idle. A naive send-then-wait would answer immediately with
// that stale idle; this must block until a real transition.
const { app } = await harness();
const pending = send(app, { input: 'run the tests', useMux: true, wait: true });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.delivered).toBe(true);
expect(body.data.duplicate).toBe(false);
expect(body.data.wait.signal).toBe('stop');
expect(body.data.wait.immediate).toBe(false);
expect(body.data.wait.timedOut).toBe(false);
});
it('uses the same data.wait envelope as the two GET endpoints', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
const { data } = (await pending).json();
expect(Object.keys(data).sort()).toEqual(['delivered', 'duplicate', 'limitPaused', 'status', 'wait']);
expect(data.status).toBe('idle');
expect(data.limitPaused).toBe(false);
expect(data.wait.aborted).toBe(false);
});
it('echoes the effective timeout after clamping', async () => {
// The schema accepts up to 24h; the server caps at MAX_WAIT_MS. Without the echo
// an agent reads the cap as "my 24h wait elapsed" and kills a healthy worker.
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 86_400_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
expect((await pending).json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('delivers the input before blocking', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const pending = send(app, { input: 'echo hi', useMux: true, wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
// The write happened while the request is still open.
expect(session.writeBuffer.join('')).toContain('echo hi');
sessionWaits.notifySignal(SESSION_ID, 'stop');
await pending;
});
it('wait: true uses the default signal set', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: true });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'idle');
expect((await pending).json().data.wait.until).toEqual(['stop', 'idle', 'exit']);
});
it('accepts an explicit signal list', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'exit' });
await new Promise((resolve) => setTimeout(resolve, 20));
// Not one of the requested signals: the wait must not resolve on it.
expect(sessionWaits.notifySignal(SESSION_ID, 'idle')).toBe(0);
sessionWaits.notifySignal(SESSION_ID, 'exit');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.until).toEqual(['exit']);
});
it('accepts an array, the same grammar the query parameter takes', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: ['stop', 'exit'] });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'exit');
const body = (await pending).json();
expect(body.data.wait.until).toEqual(['stop', 'exit']);
expect(body.data.wait.signal).toBe('exit');
});
it('rejects an unknown wait signal without writing', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const before = session.writeBuffer.length;
const res = await send(app, { input: 'x', wait: 'stpo' });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.writeBuffer.length).toBe(before);
});
it('rejects a hook-only signal for external CLI modes', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await send(app, { input: 'x', wait: 'stop' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('codex');
});
it('rejects a hook-only signal for a shell session too', async () => {
// Not an external CLI, but a plain bash PTY installs no hooks either, so `stop`
// could only ever time out.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
const res = await send(app, { input: 'x', wait: 'stop' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('shell');
});
it('times out as a 200, like the standalone wait', async () => {
const { app } = await harness();
const res = await send(app, { input: 'x', wait: 'blocked', waitTimeout: 1 });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.data.delivered).toBe(true);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.signal).toBeNull();
});
it('resolves with ended when the session goes away mid-wait', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.cancelAll(SESSION_ID);
expect((await pending).json().data.wait.ended).toBe(true);
});
it('a request-body close does NOT abort the wait', async () => {
// The regression this pins: on a POST the request stream closes as soon as the
// body has been read, well before the handler blocks. Treating that as a hang-up
// aborted every send-and-wait instantly. Hang-up handling itself is proven over
// real HTTP at the bottom of this file, because inject cannot produce a socket.
const { app, rawRequests } = await harness();
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 600_000 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
rawRequests[rawRequests.length - 1].emit('close');
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.signal).toBe('stop');
});
it('a duplicate skips the write but answers from current state instead of hanging', async () => {
// The original turn is long over, so requiring a fresh transition here would
// block a redelivery until timeout for no reason.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
await send(app, { input: 'first', clientId: 'c1', seq: 1 });
const before = session.writeBuffer.length;
const replay = await send(app, { input: 'first', clientId: 'c1', seq: 1, wait: 'idle' });
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.delivered).toBe(false);
expect(body.data.wait.immediate).toBe(true);
expect(body.data.wait.signal).toBe('idle');
expect(session.writeBuffer.length).toBe(before);
});
it('a duplicate on a BUSY session answers working, not idle', async () => {
// Previously unreachable: MockSession's status was 'working', which is not a
// SessionStatus, so signalForStatus fell through to null and this combination
// silently proved nothing.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
await send(app, { input: 'first', clientId: 'c2', seq: 1 });
session.status = 'busy';
const replay = await send(app, { input: 'first', clientId: 'c2', seq: 1, wait: 'working' });
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait.signal).toBe('working');
expect(body.data.wait.immediate).toBe(true);
expect(body.data.status).toBe('busy');
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const pendings = [];
for (let i = 0; i < 16; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
const rejected = await send(app, { input: 'x', clientId: 'c9', seq: 7, wait: true });
expect(rejected.statusCode).toBe(409);
expect(rejected.json().errorCode).toBe('SESSION_BUSY');
// Nothing written...
expect(session.writeBuffer.join('')).not.toContain('x');
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
// ...and the same seq is accepted on retry rather than treated as a replay.
const retry = await send(app, { input: 'x', clientId: 'c9', seq: 7 });
expect(retry.json()).toEqual({});
expect(session.writeBuffer.join('')).toContain('x');
});
it('a null wait is treated as absent, not as a validation error', async () => {
// Zod .optional() rejects null, and a third-party caller building the body with
// JSON.stringify keeps an explicit null on the wire.
const { app } = await harness();
const res = await send(app, { input: 'x', wait: null, waitTimeout: null });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
});
it('wait: false is treated as absent', async () => {
const { app } = await harness();
const res = await send(app, { input: 'x', wait: false });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('falls back to a direct write when the mux write fails, and still waits', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.writeViaMux = async () => false;
const pending = send(app, { input: 'fallback me', useMux: true, wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(session.writeBuffer.join('')).toContain('fallback me');
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('stop');
// The fallback write succeeded, so the input really was delivered.
expect(body.data.delivered).toBe(true);
});
});
describe('POST /api/sessions/:id/input: delivered reports the write, not just the dedup', () => {
it('reports delivered:false when BOTH write paths fail, instead of claiming delivery', async () => {
// A worker whose PTY has exited fails writeViaMux AND write. Reporting
// "delivered, but it timed out" points the agent at waiting longer; the truth is
// "restart the worker".
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.failWrites = true;
const res = await send(app, { input: 'run the tests', useMux: true, wait: 'stop', waitTimeout: 600_000 });
const body = res.json();
expect(res.statusCode).toBe(200);
expect(body.data.delivered).toBe(false);
expect(body.data.duplicate).toBe(false);
});
it('does not block for the full timeout on an input it knows never landed', async () => {
// The waiter has to be registered before the write, so it exists by the time the
// failure is known; releasing it immediately is what keeps the caller from
// waiting ten minutes for a turn that cannot start.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const started = Date.now();
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(Date.now() - started).toBeLessThan(2_000);
expect(body.data.delivered).toBe(false);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('reports delivered:false for a failed direct (non-mux) write too', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const body = (await send(app, { input: 'x', wait: 'stop', waitTimeout: 600_000 })).json();
expect(body.data.delivered).toBe(false);
});
it('rolls the dedup seq back when the write failed, so a retry is not a duplicate', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.failWrites = true;
const first = (await send(app, { input: 'x', clientId: 'c3', seq: 4, wait: 'stop', waitTimeout: 600_000 })).json();
expect(first.data.delivered).toBe(false);
session.failWrites = false;
const retry = (await send(app, { input: 'x', clientId: 'c3', seq: 4, wait: 'stop', waitTimeout: 1 })).json();
expect(retry.data.duplicate).toBe(false);
expect(retry.data.delivered).toBe(true);
expect(session.writeBuffer.join('')).toContain('x');
});
});
/**
* Client-hang-up handling, over REAL HTTP.
*
* `app.inject()` never emits a `close` event at all, so the entire abort path is
* invisible to every other test in this file — and the failure it hides is not
* subtle. On a POST, `req.raw` emits `'close'` as soon as the request BODY finishes
* streaming, which happens before the handler blocks (+1ms, `aborted: false`) and is
* indistinguishable from a real hang-up at +0ms. A request-side abort listener
* therefore cancels every send-and-wait instantly: `POST .../input {wait:"exit",
* waitTimeout:10000}` came back in 23ms with `ended:true, aborted:true, waitedMs:6`,
* i.e. the feature was dead while all 27 inject-based tests above stayed green.
*
* GET survives a request-side listener because it has no body to finish, which is
* exactly why this regression needs a POST and a real socket to catch.
*/
describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => {
const PORT = 3181;
const base = `http://127.0.0.1:${PORT}`;
let app: FastifyInstance;
beforeAll(async () => {
app = (await harness()).app;
await app.listen({ port: PORT, host: '127.0.0.1' });
});
afterAll(async () => {
// fetch keeps its sockets alive, and `app.close()` waits for idle connections,
// so without this the teardown hook times out.
app.server.closeAllConnections();
await app.close();
});
it('a send-and-wait that is NOT aborted blocks for its full timeout', async () => {
// The regression: this returned in ~20ms with aborted:true.
const started = Date.now();
const res = await fetch(`${base}/api/sessions/${SESSION_ID}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'run the tests', wait: 'exit', waitTimeout: 2000 }),
});
const elapsed = Date.now() - started;
const body = await res.json();
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.waitedMs).toBeGreaterThan(1500);
expect(elapsed).toBeGreaterThan(1500);
expect(body.data.delivered).toBe(true);
});
it('a send-and-wait aborted mid-flight frees its waiter', async () => {
const controller = new AbortController();
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: 'x', wait: 'exit', waitTimeout: 600_000 }),
signal: controller.signal,
}).catch(() => 'aborted');
await new Promise((resolve) => setTimeout(resolve, 150));
// Still parked: the body finished streaming long ago, and that must not count.
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
controller.abort();
expect(await pending).toBe('aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('a GET wait behaves the same way on both counts', async () => {
const notAborted = await fetch(`${base}/api/sessions/${SESSION_ID}/wait?until=stop&timeout=1500`);
const body = await notAborted.json();
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.timedOut).toBe(true);
const controller = new AbortController();
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000`, {
signal: controller.signal,
}).catch(() => 'aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
controller.abort();
expect(await pending).toBe('aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('wait-output frees its waiter on hang-up too', async () => {
const controller = new AbortController();
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/wait-output?match=NEVER&timeout=600000`, {
signal: controller.signal,
}).catch(() => 'aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
controller.abort();
expect(await pending).toBe('aborted');
await new Promise((resolve) => setTimeout(resolve, 100));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
});
});
/**
* Send-and-wait against a tmux worker that has already died.
*
* `tmux send-keys` SUCCEEDS against a dead pane, so `writeViaMux` returns true and the
* old `delivered` was true for bytes written into a corpse — with `timedOut: true`
* alongside it, which tells an agent to wait longer when the truth is "restart the
* worker". Live: `pane_dead=1 status=42`, Codeman `pid=309406 status=idle`,
* `delivered: true`.
*/
describe('POST /api/sessions/:id/input: the pane is dead', () => {
function setPaneDead(ctx: MockRouteContext, dead: boolean) {
(ctx.mux as unknown as { isPaneDead: (n: string) => boolean }).isPaneDead = () => dead;
}
it('reports delivered:false even though the mux write "succeeded"', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
setPaneDead(ctx, true);
const res = await send(app, { input: 'run the tests', useMux: true, wait: 'stop', waitTimeout: 600_000 });
const body = res.json();
// The write itself did not fail — that is the whole trap.
expect(session.writeBuffer.join('')).toContain('run the tests');
expect(body.data.delivered).toBe(false);
expect(body.data.duplicate).toBe(false);
});
it('returns at once instead of blocking on a turn that cannot start', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, true);
const started = Date.now();
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(Date.now() - started).toBeLessThan(2_000);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
});
it('rolls the dedup seq back, so a retry against a restarted worker is not a duplicate', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
setPaneDead(ctx, true);
const dead = (await send(app, { input: 'x', useMux: true, clientId: 'c7', seq: 3, wait: 'stop' })).json();
expect(dead.data.delivered).toBe(false);
// Worker restarted.
setPaneDead(ctx, false);
_resetPaneLivenessState();
const retry = (
await send(app, { input: 'x', useMux: true, clientId: 'c7', seq: 3, wait: 'stop', waitTimeout: 1 })
).json();
expect(retry.data.duplicate).toBe(false);
expect(retry.data.delivered).toBe(true);
expect(session.writeBuffer.join('')).toContain('x');
});
it('a live pane still reports delivered:true', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
const pending = send(app, { input: 'x', useMux: true, wait: 'stop' });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
expect((await pending).json().data.delivered).toBe(true);
});
it('never probes tmux on the plain (non-wait) input path', async () => {
// The browser sends thousands of these per session; they must not exec tmux.
const { app, ctx } = await harness();
const probe = vi.fn(() => false);
(ctx.mux as unknown as { isPaneDead: (n: string) => boolean }).isPaneDead = probe as never;
await send(app, { input: 'hello', useMux: true });
await send(app, { input: 'hello again', useMux: true, clientId: 'c1', seq: 1 });
expect(probe).not.toHaveBeenCalled();
});
});
describe('POST /api/sessions/:id/input: `aborted` stays a client-side fact', () => {
it('reports aborted:false when the SERVER released the waiter after a failed delivery', async () => {
// api-reference guarantees a client never sees `aborted: true`, because it means
// "you hung up, nobody is reading this". The release below is the server giving up
// on a write that failed — and the client IS reading the response, so reporting
// `aborted: true` would both break that guarantee and hand an agent a second,
// contradictory reason for an outcome `delivered: false` already explains.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.failWrites = true;
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(body.data.delivered).toBe(false);
expect(body.data.wait.aborted).toBe(false);
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
});
it('the same holds for a dead pane', async () => {
const { app, ctx } = await harness();
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => true;
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
expect(body.data.wait.aborted).toBe(false);
});
});
describe('POST /api/sessions/:id/input: an oversized waitTimeout clamps', () => {
it('accepts a value above the old schema ceiling and reports the clamp', async () => {
const { app } = await harness();
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 99_999_999_999 });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('still rejects a non-integer or negative waitTimeout', async () => {
const { app } = await harness();
for (const value of [-1, 0, 1.5]) {
const res = await send(app, { input: 'x', wait: 'stop', waitTimeout: value });
expect(res.statusCode, `waitTimeout=${value}`).toBe(400);
}
});
});
@@ -0,0 +1,432 @@
/**
* @fileoverview Route tests for `GET /api/sessions/:id/wait-output`.
*
* Same 200-on-timeout contract and same `data.wait` envelope as `/wait`. The
* additional things pinned here:
* - matching is LITERAL, and a `regex` parameter is rejected rather than ignored,
* so an agent that assumed herdr's `--regex` cannot silently wait on the wrong thing;
* - `from=buffer` scans what already scrolled past, bounded to a tail of the buffer,
* and is charged against the waiter cap BEFORE it materializes that buffer;
* - a chunk-straddling match still resolves, since PTY chunking is arbitrary;
* - a client that hangs up frees its waiter instead of holding it to the timeout.
*
* Plan: docs/agent-control-plan.md
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import type { ServerResponse } from 'node:http';
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
import { createSessionListeners, attachSessionListeners } from '../../src/web/session-listener-wiring.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_MATCH_LENGTH, MAX_BUFFER_SCAN_BYTES, MAX_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
const SESSION_ID = 'wait-output-session';
const URL = `/api/sessions/${SESSION_ID}/wait-output`;
afterEach(() => {
// Deliberately not `cancelEverything()`: it latches the registry's stopped flag,
// which would leave every later test in this file talking to a dead registry.
sessionWaits.cancelAll(SESSION_ID);
_resetPaneLivenessState();
});
/** Mirrors production's errorCode-to-status mapping; without it negative cases pass vacuously. */
async function harness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext; rawReplies: ServerResponse[] }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
const rawReplies: ServerResponse[] = [];
app.addHook('onRequest', async (req, reply) => {
// The RESPONSE, because that is what the handler's hang-up detection listens to.
rawReplies.push(reply.raw);
});
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, rawReplies };
}
describe('GET /api/sessions/:id/wait-output', () => {
it('resolves when the string appears on the stream', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifyOutput(SESSION_ID, 'running tests...\nBUILD OK\n');
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.wait.matched).toBe(true);
expect(body.data.wait.immediate).toBe(false);
expect(body.data.wait.snippet).toContain('BUILD OK');
expect(body.data.wait.match).toBe('BUILD OK');
expect(body.data.sessionId).toBe(SESSION_ID);
});
it('uses the same data.wait envelope as /wait, so one client helper reads both', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
const { data } = res.json();
expect(Object.keys(data).sort()).toEqual(['limitPaused', 'sessionId', 'status', 'wait']);
expect(data.matched).toBeUndefined();
expect(data.wait.matched).toBe(false);
expect(data.wait.aborted).toBe(false);
});
it('sends Cache-Control: no-store', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
expect(res.headers['cache-control']).toBe('no-store');
});
it('echoes the effective timeout after clamping', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'BUILD OK\n';
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&from=buffer&timeout=1800000` });
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('strips ANSI before matching, so colored output still matches', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, '\x1b[32mBUILD\x1b[0m OK\n');
expect((await pending).json().data.wait.matched).toBe(true);
});
it('matches across a chunk boundary', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, 'trailing text BUIL');
sessionWaits.notifyOutput(SESSION_ID, 'D OK done');
expect((await pending).json().data.wait.matched).toBe(true);
});
it('is case-sensitive by default and honors nocase=1', async () => {
const { app } = await harness();
const strict = app.inject({ method: 'GET', url: `${URL}?match=build%20ok&timeout=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, 'BUILD OK');
expect((await strict).json().data.wait.timedOut).toBe(true);
const loose = app.inject({ method: 'GET', url: `${URL}?match=build%20ok&nocase=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifyOutput(SESSION_ID, 'BUILD OK');
const body = (await loose).json();
expect(body.data.wait.matched).toBe(true);
// Reported in the terminal's own casing, not the caller's.
expect(body.data.wait.snippet).toContain('BUILD OK');
});
it('from=buffer resolves immediately against output that already scrolled past', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'earlier output\n\x1b[32mBUILD OK\x1b[0m\n';
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&from=buffer` });
const body = res.json();
expect(body.data.wait.matched).toBe(true);
expect(body.data.wait.immediate).toBe(true);
expect(body.data.wait.waitedMs).toBe(0);
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
it('from=buffer only scans a bounded tail', async () => {
const { app, ctx } = await harness();
// Old marker pushed past the scan window by newer output.
ctx.sessions.get(SESSION_ID)!.terminalBuffer = `ANCIENT${'x'.repeat(MAX_BUFFER_SCAN_BYTES + 1000)}`;
const res = await app.inject({ method: 'GET', url: `${URL}?match=ANCIENT&from=buffer&timeout=1` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.timedOut).toBe(true);
});
it('defaults to from=now, ignoring what is already in the buffer', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'BUILD OK happened before you asked\n';
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&timeout=1` });
expect(res.json().data.wait.timedOut).toBe(true);
});
it('answers 200 with timedOut on timeout, never an error status', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.matched).toBe(false);
expect(body.data.wait.snippet).toBeNull();
});
it('resolves with ended when the session goes away mid-wait', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=never` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.cancelAll(SESSION_ID);
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.matched).toBe(false);
});
it('frees its waiter when the RESPONSE socket closes early', async () => {
// The injected response is genuinely destroyed by then, so the freed slot is what
// this can assert; the wire-level behaviour is pinned over real HTTP in
// session-input-wait.test.ts.
const { app, rawReplies } = await harness();
const pending = app.inject({ method: 'GET', url: `${URL}?match=never&timeout=600000` }).then(
() => 'completed',
() => 'destroyed'
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
rawReplies[rawReplies.length - 1].emit('close');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
expect(await pending).toBe('destroyed');
});
it('rejects a regex parameter instead of silently ignoring it', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&regex=%5EBUILD.*OK%24` });
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('match=');
});
it('requires match', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('rejects an empty or oversized match, and says which parameter was wrong', async () => {
const { app } = await harness();
const empty = await app.inject({ method: 'GET', url: `${URL}?match=` });
expect(empty.statusCode).toBe(400);
expect(empty.json().error).toContain('match');
const huge = await app.inject({ method: 'GET', url: `${URL}?match=${'x'.repeat(MAX_MATCH_LENGTH + 1)}` });
expect(huge.statusCode).toBe(400);
expect(huge.json().error).toContain('match');
});
it('rejects a non-numeric timeout', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&timeout=soon` });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(res.json().error).toContain('timeout');
});
it('rejects an unknown from value', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&from=history` });
expect(res.statusCode).toBe(400);
});
it('404s an unknown session', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/wait-output?match=x' });
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('output waiters share the session waiter cap with signal waiters', async () => {
const { app } = await harness();
const pendings = [];
for (let i = 0; i < 8; i++) {
pendings.push(app.inject({ method: 'GET', url: `${URL}?match=never${i}` }));
}
for (let i = 0; i < 8; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
const overflow = await app.inject({ method: 'GET', url: `${URL}?match=one-too-many` });
expect(overflow.statusCode).toBe(409);
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
});
it('checks the cap BEFORE materializing the terminal buffer', async () => {
// `session.terminalBuffer` joins the whole 32MB accumulator. Paying that for a
// request that is about to be refused turns the cap into an amplifier: a caller
// already at the limit can loop `from=buffer` at full speed and never register a
// waiter, so nothing bounds the work.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const bufferReads = vi.fn(() => 'nothing to see');
Object.defineProperty(session, 'terminalBuffer', { get: bufferReads, configurable: true });
const pendings = [];
for (let i = 0; i < 16; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
const overflow = await app.inject({ method: 'GET', url: `${URL}?match=x&from=buffer` });
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
expect(bufferReads).not.toHaveBeenCalled();
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
});
});
/**
* The LIVE-STREAM half, wired the way production wires it.
*
* Everything above drives `sessionWaits.notifyOutput()` directly, which is the
* registry's API, not the path a real session takes. Deleting the one line that
* connects them — `sessionWaits.notifyOutput(session.id, data)` in the `terminal`
* listener — left all four wait suites green and survived a full `test:ci` sweep, so
* `wait-output?from=now` (the entire live mode, and the one the skill's recipes are
* built on) could ship severed with nothing to show for it.
*
* These go through `createSessionListeners` so the wiring itself is what is pinned.
*/
describe('GET /api/sessions/:id/wait-output: fed by the real terminal listener', () => {
function stubDeps() {
return {
broadcast: vi.fn(),
batchTerminalData: vi.fn(),
batchTaskUpdate: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
persistSessionState: vi.fn(),
getSessionStateWithRespawn: vi.fn(() => ({})),
getRunSummaryTracker: vi.fn(() => undefined),
stopTranscriptWatcher: vi.fn(),
cleanupSessionBatches: vi.fn(),
cancelPersistDebounce: vi.fn(),
removeRunSummaryTracker: vi.fn(),
removeSessionListenerRefs: vi.fn(),
cleanupRespawnOnExit: vi.fn(),
getStore: vi.fn(() => ({ updateRalphState: vi.fn() })),
registerAttachment: vi.fn(async () => {}),
};
}
it('matches output emitted by the session, not injected into the registry', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const deps = stubDeps();
attachSessionListeners(session as never, createSessionListeners(session as never, deps as never));
const pending = app.inject({ method: 'GET', url: `${URL}?match=LIVE_STREAM_HIT` });
await new Promise((resolve) => setTimeout(resolve, 20));
// What a PTY chunk actually does: the session emits `terminal`.
session.simulateTerminalOutput('$ echo LIVE_STREAM_HIT\r\nLIVE_STREAM_HIT\r\n');
const body = (await pending).json();
expect(body.data.wait.matched).toBe(true);
expect(body.data.wait.snippet).toContain('LIVE_STREAM_HIT');
// The listener must still forward to the SSE batcher; the wait feed is additive.
expect(deps.batchTerminalData).toHaveBeenCalled();
});
it('matches across chunk boundaries through the listener', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `${URL}?match=SPLIT_MARKER` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateTerminalOutput('noise SPLIT_');
session.simulateTerminalOutput('MARKER more noise');
expect((await pending).json().data.wait.matched).toBe(true);
});
it('strips ANSI on the way through the listener', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `${URL}?match=COLORED%20HIT` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.simulateAnsiOutput('COLORED HIT');
expect((await pending).json().data.wait.matched).toBe(true);
});
});
describe('GET /api/sessions/:id/wait-output: a dead tmux worker', () => {
it('releases an output waiter when the worker dies while it is parked', async () => {
// The feed simply stops: no exit event, no further chunks, nothing to match.
const { app, ctx } = await harness();
let dead = false;
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
const pending = app.inject({ method: 'GET', url: `${URL}?match=NEVER&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
dead = true;
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
}, 10_000);
it('an oversized timeout clamps rather than 400ing', async () => {
const { app, ctx } = await harness();
// Resolve from the buffer so the assertion is about the clamp, not a real wait.
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'ALREADY_THERE\n';
const res = await app.inject({
method: 'GET',
url: `${URL}?match=ALREADY_THERE&from=buffer&timeout=99999999`,
});
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
});
+811
View File
@@ -0,0 +1,811 @@
/**
* @fileoverview Route tests for `GET /api/sessions/:id/wait`.
*
* The contract this pins is the one an orchestrating agent depends on:
* - a timeout is a 200 with `wait.timedOut: true`, never a 4xx, because callers loop
* over short waits and every poll boundary would otherwise look like a failure;
* - the result is nested under `data.wait` on ALL THREE wait endpoints, so a single
* client helper reads any of them;
* - the EFFECTIVE timeout is echoed, so a caller that asked for 30 minutes and was
* clamped to 10 can tell a poll boundary from a wedged worker;
* - an unknown `until` token is a 400 rather than a silent fallback to the default,
* so a typo can never leave an agent believing it is waiting for something else,
* and a schema 400 names the parameter it rejected;
* - `stop`/`blocked` are rejected for modes that install no hooks when asked for
* EXPLICITLY, but silently dropped from the DEFAULT set, so omitting `until` never
* 400s — and `shell` counts as such a mode, even though it is not an external CLI;
* - a client that hangs up frees its waiter immediately, or a loop of
* `curl --max-time` calls wedges a process-wide cap nobody else can use;
* - a session with no PTY answers `exit`, never `idle`.
*
* Plan: docs/agent-control-plan.md
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import Fastify, { type FastifyInstance } from 'fastify';
import type { ServerResponse } from 'node:http';
import {
registerSessionRoutes,
_resetPaneLivenessState,
_paneDeathWatcherCount,
} from '../../src/web/routes/session-routes.js';
import { createSessionListeners, attachSessionListeners } from '../../src/web/session-listener-wiring.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
const SESSION_ID = 'wait-routes-session';
/** Session ids a test parked filler waiters on, so cleanup can release them. */
const fillerIds = new Set<string>();
/** Park a waiter directly on the shared registry (cap tests), tracked for cleanup. */
function fillWaiter(id: string, owner?: string): Promise<unknown> {
fillerIds.add(id);
return sessionWaits.waitForSignal(id, { until: ['stop'], timeoutMs: 30_000, owner });
}
afterEach(() => {
// The routes use the process-wide registry; never leak a waiter into the next test.
// Deliberately NOT `cancelEverything()`: it latches the registry's stopped flag
// (one-way by design, so a request landing mid-shutdown cannot register a waiter
// nothing will ever cancel), which would leave every later test in this file
// talking to a dead registry and passing vacuously.
for (const id of [SESSION_ID, ...fillerIds]) sessionWaits.cancelAll(id);
fillerIds.clear();
// Pane-liveness state is module-level (one cache, one watcher per pane), so it has
// to be reset or a cached probe leaks into the next test.
_resetPaneLivenessState();
delete process.env.CODEMAN_MULTIUSER;
});
/**
* The shared route harness returns handler payloads verbatim, so a `{success:false}`
* body would still be HTTP 200. Production maps errorCode to status in server.ts, so
* mirror that here or every negative case passes vacuously.
*
* `rawReplies` collects each request's `reply.raw` — the RESPONSE, which is what the
* handler's hang-up detection listens on, and deliberately not `req.raw` (on a POST
* that one closes as soon as the body has been read, so wiring an abort to it kills
* every send-and-wait; see the real-HTTP suite in session-input-wait.test.ts).
* Emitting the event by hand is the only way to simulate a hang-up here at all:
* `app.inject()` never emits `close` on its own, verified.
*/
async function harness(options?: { authUser?: { username: string; role: 'admin' | 'user' } }): Promise<{
app: FastifyInstance;
ctx: MockRouteContext;
rawReplies: ServerResponse[];
}> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
const rawReplies: ServerResponse[] = [];
const authUser = options?.authUser;
app.addHook('onRequest', async (req, reply) => {
// The RESPONSE, because that is what the handler's hang-up detection listens to.
rawReplies.push(reply.raw);
if (authUser) (req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
registerSessionRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
const p = payload as { success?: unknown; errorCode?: unknown } | null;
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx, rawReplies };
}
describe('GET /api/sessions/:id/wait', () => {
it('resolves immediately when the session is already in a requested state', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.wait.signal).toBe('idle');
expect(body.data.wait.immediate).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(body.data.wait.aborted).toBe(false);
expect(body.data.sessionId).toBe(SESSION_ID);
expect(body.data.wait.until).toEqual(['idle']);
});
it('nests the result under data.wait, so one client helper reads all three endpoints', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
const { data } = res.json();
// Session-level facts stay at the top; everything about the wait is inside it.
expect(Object.keys(data).sort()).toEqual(['limitPaused', 'sessionId', 'status', 'wait']);
expect(data.signal).toBeUndefined();
});
it('reports the post-wait status and the limit-pause hint', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
const body = res.json();
expect(body.data.status).toBe('idle');
expect(body.data.limitPaused).toBe(false);
});
it('sends Cache-Control: no-store, so a polled long-poll cannot be served from a cache', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.headers['cache-control']).toBe('no-store');
});
it('defaults to stop,idle,exit when until is omitted', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
const body = res.json();
expect(body.data.wait.until).toEqual(['stop', 'idle', 'exit']);
// The mock session is idle, so the default set resolves right away.
expect(body.data.wait.signal).toBe('idle');
});
it('rejects an unknown until token instead of falling back to the default', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stpo` });
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toContain('stpo');
});
it('rejects a non-numeric timeout', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=soon` });
expect(res.statusCode).toBe(400);
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('names the parameter it rejected, instead of a bare "invalid parameters"', async () => {
// An agent driving this with no docs in context can only recover if the error
// says WHICH parameter was wrong; the old message named none of them.
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=30s` });
const body = res.json();
expect(body.error).toContain('timeout');
expect(body.error).toContain('wait');
});
it('404s an unknown session', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/wait?until=idle' });
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it('resolves an in-flight wait when the signal arrives', async () => {
const { app } = await harness();
// `stop` is not the session's current signal, so this blocks.
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'stop');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('stop');
expect(body.data.wait.immediate).toBe(false);
expect(body.data.wait.timedOut).toBe(false);
expect(body.data.wait.waitedMs).toBeGreaterThanOrEqual(0);
});
it('fresh=1 waits for the next transition instead of answering from current state', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&fresh=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
sessionWaits.notifySignal(SESSION_ID, 'idle');
const body = (await pending).json();
expect(body.data.wait.signal).toBe('idle');
expect(body.data.wait.immediate).toBe(false);
});
it('resolves with ended when the session goes away mid-wait', async () => {
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.cancelAll(SESSION_ID);
const body = (await pending).json();
expect(body.success).toBe(true);
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.signal).toBeNull();
expect(body.data.wait.timedOut).toBe(false);
});
it('answers 200 with timedOut on timeout, never an error status', async () => {
const { app } = await harness();
// Clamped up to the 1s floor, so this is the one deliberately slow case.
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=1` });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.wait.timedOut).toBe(true);
expect(body.data.wait.signal).toBeNull();
expect(body.data.wait.ended).toBe(false);
});
});
describe('GET /api/sessions/:id/wait: the effective timeout is observable', () => {
it('echoes the clamped-down value when the caller asks for more than the ceiling', async () => {
// Asked for 30 minutes, got MAX_WAIT_MS. Without the echo the caller reads a
// 10-minute timeout as "30 minutes elapsed with no stop" and kills a healthy worker.
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1800000` });
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('echoes the clamped-up value at the floor too', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
expect(res.json().data.wait.timeoutMs).toBe(MIN_WAIT_MS);
});
it('reports the applied default when timeout is omitted', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.json().data.wait.timeoutMs).toBeGreaterThanOrEqual(MIN_WAIT_MS);
expect(res.json().data.wait.timeoutMs).toBeLessThanOrEqual(MAX_WAIT_MS);
});
});
describe('GET /api/sessions/:id/wait: repeated query parameters', () => {
it('accepts ?until=stop&until=exit, the way most clients express a list', async () => {
// Fastify delivers a repeated parameter as an array and parseWaitSignals has
// always handled one; only the schema was rejecting it.
const { app } = await harness();
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&until=exit` });
await new Promise((resolve) => setTimeout(resolve, 20));
sessionWaits.notifySignal(SESSION_ID, 'exit');
const body = (await pending).json();
expect(body.data.wait.until).toEqual(['stop', 'exit']);
expect(body.data.wait.signal).toBe('exit');
});
it('still reports an unknown token inside a repeated parameter', async () => {
const { app } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&until=stpo` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('stpo');
});
});
describe('GET /api/sessions/:id/wait: a client that hangs up frees its waiter', () => {
it('removes the waiter when the RESPONSE socket closes early', async () => {
// `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter every
// iteration of the documented loop; sixteen of those and an innocent session
// reports busy.
//
// The response body is unreadable afterwards (the injected response really is
// destroyed, exactly as a hung-up socket would be), so the freed slot is all this
// can assert. The full behaviour, including `aborted: true` on the wire for the
// caller that did NOT hang up, is pinned over real HTTP in session-input-wait.test.ts.
const { app, rawReplies } = await harness();
const pending = app
.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` })
.then(
() => 'completed',
() => 'destroyed'
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
rawReplies[rawReplies.length - 1].emit('close');
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
expect(await pending).toBe('destroyed');
});
it('a close AFTER the wait resolved changes nothing', async () => {
const { app, rawReplies } = await harness();
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.json().data.wait.aborted).toBe(false);
// Node fires `close` on every completed response too, not only on a hang-up;
// `writableFinished` is what separates them.
rawReplies[rawReplies.length - 1].emit('close');
expect(sessionWaits.totalWaiterCount()).toBe(0);
});
});
describe('GET /api/sessions/:id/wait: capacity errors name the cap that was hit', () => {
it('maps the per-session cap to SESSION_BUSY / 409', async () => {
const { app } = await harness();
const pendings = [];
for (let i = 0; i < 16; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
}
await new Promise((resolve) => setTimeout(resolve, 30));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(16);
const overflow = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(overflow.statusCode).toBe(409);
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
expect(overflow.json().error).toContain('session');
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
});
it('maps the process-wide cap to RATE_LIMITED / 429, because this session is not the problem', async () => {
// Reported as SESSION_BUSY, an agent concludes the session it asked about is
// busy, switches to another, and gets the identical error.
const { app } = await harness();
const others: Promise<unknown>[] = [];
for (let i = 0; i < MAX_WAITERS_TOTAL; i++) others.push(fillWaiter(`unrelated-${i}`));
expect(sessionWaits.totalWaiterCount()).toBe(MAX_WAITERS_TOTAL);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(429);
expect(res.json().errorCode).toBe('RATE_LIMITED');
expect(res.json().error).toContain('total');
for (const id of fillerIds) sessionWaits.cancelAll(id);
await Promise.all(others);
});
it('maps the per-owner cap to RATE_LIMITED / 429 and charges the request to its user', async () => {
// Also proves the route passes ownerFor(req): without it the owner cap can never
// trip, and one user could hold the whole process-wide pool.
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ authUser: { username: 'alice', role: 'admin' } });
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.ownerWaiterCount('alice')).toBe(1);
const others: Promise<unknown>[] = [];
while (sessionWaits.ownerWaiterCount('alice') < MAX_WAITERS_PER_OWNER) {
others.push(fillWaiter(`alice-${others.length}`, 'alice'));
}
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(429);
expect(res.json().error).toContain('owner');
sessionWaits.cancelAll(SESSION_ID);
for (const id of fillerIds) sessionWaits.cancelAll(id);
await Promise.all([pending, ...others]);
});
});
describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
it('rejects an explicit stop, which no external CLI ever emits', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('codex');
});
it('rejects an explicit blocked too', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'opencode';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked` });
expect(res.statusCode).toBe(400);
});
it('rejects stop for a SHELL session, which is not an external CLI but installs no hooks either', async () => {
// A plain bash PTY never POSTs a Stop hook, so this was a guaranteed ten-minute
// hang dressed up as a timeout — the exact failure the guard exists to prevent.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('shell');
});
it('silently drops hook-only signals from the DEFAULT set instead of 400ing', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'gemini';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
});
it('drops them from the default set for shell too', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
});
it('still accepts idle and exit explicitly', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'antigravity';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,exit` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
});
});
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
// Session parks a dead PTY at status 'idle' and the object survives in the map,
// so the DEFAULT wait used to answer {signal:"idle", immediate:true} for a
// crashed worker — 200, success, no error, and the agent prompts a corpse.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.pid = null;
session.status = 'idle';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
const body = res.json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.immediate).toBe(true);
// The raw status is still reported, so nothing is hidden from the caller.
expect(body.data.status).toBe('idle');
});
it('resolves until=exit immediately for an already-exited session instead of blocking', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.pid = null;
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&timeout=1` });
expect(res.json().data.wait.signal).toBe('exit');
expect(res.json().data.wait.timedOut).toBe(false);
});
it('does not report idle for a dead session even when idle was asked for explicitly', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.pid = null;
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
expect(res.json().data.wait.signal).toBeNull();
expect(res.json().data.wait.timedOut).toBe(true);
});
it('a live busy session resolves until=working immediately', async () => {
// Unreachable before: MockSession used 'working', which is not a SessionStatus,
// so signalForStatus fell through to null and this branch had no coverage.
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.status = 'busy';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=working` });
expect(res.json().data.wait.signal).toBe('working');
expect(res.json().data.wait.immediate).toBe(true);
});
it('a live stopped/error session maps to exit', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.status = 'stopped';
const stopped = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit` });
expect(stopped.json().data.wait.signal).toBe('exit');
session.status = 'error';
const errored = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit` });
expect(errored.json().data.wait.signal).toBe('exit');
});
});
/**
* The other half of the wait contract, exercised through the real listener wiring
* rather than a route: a PTY that dies must RELEASE every waiter, not only the ones
* that asked for `exit`.
*
* It lives in this file because it pins the same promise the routes above make
* ("never hang"), and because the failure is only visible from the caller's side:
* the exit handler detaches the `terminal`, `idle` and `working` listeners moments
* later, so anything still registered afterwards is waiting on feeds that no longer
* exist and can only time out.
*/
describe('a PTY exit releases waiters that did not ask for exit', () => {
/** Everything the exit handler touches; the wait release must not depend on any of it. */
function stubDeps(overrides: Record<string, unknown> = {}) {
return {
broadcast: vi.fn(),
batchTerminalData: vi.fn(),
batchTaskUpdate: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
persistSessionState: vi.fn(),
getSessionStateWithRespawn: vi.fn(() => ({})),
getRunSummaryTracker: vi.fn(() => undefined),
stopTranscriptWatcher: vi.fn(),
cleanupSessionBatches: vi.fn(),
cancelPersistDebounce: vi.fn(),
removeRunSummaryTracker: vi.fn(),
removeSessionListenerRefs: vi.fn(),
cleanupRespawnOnExit: vi.fn(),
getStore: vi.fn(() => ({ updateRalphState: vi.fn() })),
registerAttachment: vi.fn(async () => {}),
...overrides,
};
}
it('answers an until=working waiter with ended instead of leaving it to time out', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=working&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
session.emit('exit', 1);
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(body.data.wait.signal).toBeNull();
});
it('still gives an until=exit waiter its signal, not a bare ended', async () => {
// Ordering matters: notifySignal('exit') must run BEFORE cancelAll, or a caller
// that asked the right question gets the generic answer.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&fresh=1` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.emit('exit', 0);
const body = (await pending).json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.ended).toBe(false);
});
it('releases output waiters too, whose only feed the exit handler is about to detach', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({
method: 'GET',
url: `/api/sessions/${SESSION_ID}/wait-output?match=DONE&timeout=600000`,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
session.emit('exit', 1);
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.matched).toBe(false);
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(0);
});
it('releases them even when a later step of the exit handler throws', async () => {
// Which is why the release is the first thing in the handler.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
const deps = stubDeps({
broadcast: vi.fn(() => {
throw new Error('SSE is down');
}),
});
attachSessionListeners(session as never, createSessionListeners(session as never, deps as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
session.emit('exit', 1);
expect((await pending).json().data.wait.ended).toBe(true);
});
});
/**
* Worker liveness for a tmux-backed session.
*
* `session.pid` is the local `tmux attach` CLIENT, not the worker. Codeman sets
* `remain-on-exit on`, so when the command inside the pane exits tmux keeps the pane
* (`pane_dead=1`), the tmux session survives, the attach client keeps running, `pid`
* never goes null and NO exit event fires. Reproduced live on a shell worker killed
* with `exit 42`: tmux said `pane_dead=1 status=42` while Codeman said
* `pid=309406 status=idle` and the default wait answered
* `{signal:"idle", immediate:true, waitedMs:0}` for a corpse.
*
* These cases could not exist before, because `MockSession.pid` is set by hand: the
* `pid === null` branch is the one production never reaches.
*/
describe('GET /api/sessions/:id/wait: a dead tmux worker', () => {
/** Mock ctx doubles carry no `isPaneDead`; the route treats that as "cannot tell". */
function setPaneDead(ctx: MockRouteContext, dead: boolean): ReturnType<typeof vi.fn> {
const probe = vi.fn(() => dead);
(ctx.mux as unknown as { isPaneDead: (name: string) => boolean }).isPaneDead = probe as never;
return probe;
}
it('answers exit, not the idle the session still reports', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
setPaneDead(ctx, true);
// Exactly the live state: attach client alive, status idle, worker gone.
expect(session.pid).not.toBeNull();
expect(session.status).toBe('idle');
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
const body = res.json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.immediate).toBe(true);
// The raw status is still reported, so nothing is hidden from the caller.
expect(body.data.status).toBe('idle');
});
it('resolves until=exit immediately instead of burning the whole timeout', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, true);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&timeout=1` });
expect(res.json().data.wait.signal).toBe('exit');
expect(res.json().data.wait.timedOut).toBe(false);
});
it('does not answer idle for a dead worker even when idle was asked for explicitly', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, true);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
expect(res.json().data.wait.signal).toBeNull();
expect(res.json().data.wait.timedOut).toBe(true);
});
it('a live pane is unaffected', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(res.json().data.wait.signal).toBe('idle');
});
it('caches the probe, so a poll loop cannot exec tmux once per request', async () => {
const { app, ctx } = await harness();
const probe = setPaneDead(ctx, false);
for (let i = 0; i < 10; i++) {
await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
}
expect(probe.mock.calls.length).toBeLessThanOrEqual(2);
});
it('never probes a session that is not tmux-backed', async () => {
const { app, ctx } = await harness();
const probe = setPaneDead(ctx, true);
ctx.sessions.get(SESSION_ID)!.usesMux = false;
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(probe).not.toHaveBeenCalled();
// Falls back to the pid rule, which is the right one for a direct PTY.
expect(res.json().data.wait.signal).toBe('idle');
});
it('releases a wait when the worker dies WHILE it is parked', async () => {
// The common orchestration case, and the one a request-time probe cannot see: no
// exit event, no output, nothing — the caller would block for its full timeout.
const { app, ctx } = await harness();
let dead = false;
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
expect(_paneDeathWatcherCount()).toBe(1);
dead = true;
const body = (await pending).json();
expect(body.data.wait.ended).toBe(true);
expect(body.data.wait.timedOut).toBe(false);
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
// ...and the watcher is torn down with the last waiter that needed it.
expect(_paneDeathWatcherCount()).toBe(0);
}, 10_000);
it('an until=exit caller parked when the worker dies gets its signal, not a bare ended', async () => {
const { app, ctx } = await harness();
let dead = false;
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
const pending = app.inject({
method: 'GET',
url: `/api/sessions/${SESSION_ID}/wait?until=exit&fresh=1&timeout=600000`,
});
await new Promise((resolve) => setTimeout(resolve, 50));
dead = true;
const body = (await pending).json();
expect(body.data.wait.signal).toBe('exit');
expect(body.data.wait.ended).toBe(false);
}, 10_000);
it('starts no watcher at all when the session is not tmux-backed', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
ctx.sessions.get(SESSION_ID)!.usesMux = false;
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 30));
expect(_paneDeathWatcherCount()).toBe(0);
sessionWaits.cancelAll(SESSION_ID);
await pending;
});
it('shares ONE watcher across every wait parked on the same session', async () => {
const { app, ctx } = await harness();
setPaneDead(ctx, false);
const pendings = [];
for (let i = 0; i < 5; i++) {
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` }));
}
await new Promise((resolve) => setTimeout(resolve, 40));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(5);
expect(_paneDeathWatcherCount()).toBe(1);
sessionWaits.cancelAll(SESSION_ID);
await Promise.all(pendings);
expect(_paneDeathWatcherCount()).toBe(0);
});
});
describe('GET /api/sessions/:id/wait: an oversized timeout clamps, it does not 400', () => {
it('accepts a value above the old schema ceiling and reports the clamp', async () => {
// "Clamped to [1000, 600000]" has to mean it: `timeout=600001` clamping while
// `timeout=99999999` 400s is the same documented rule producing two outcomes.
const { app } = await harness();
const res = await app.inject({
method: 'GET',
url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=99999999`,
});
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
});
it('still rejects a non-finite or non-integer timeout', async () => {
const { app } = await harness();
for (const value of ['1e999', 'soon', '-1', '1.5']) {
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=${value}` });
expect(res.statusCode, `timeout=${value}`).toBe(400);
}
});
});
+35
View File
@@ -64,3 +64,38 @@ describe('buildMuxAttachEnv', () => {
} }
}); });
}); });
describe('spawn env CODEMAN_API_URL (no fallback)', () => {
const withApiUrl = (value: string | undefined, fn: () => void) => {
const original = process.env.CODEMAN_API_URL;
if (value === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = value;
try {
fn();
} finally {
if (original === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = original;
}
};
it('passes the server-stamped URL through verbatim', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
withApiUrl('https://127.0.0.1:3199', () => {
expect(buildClaudeEnv('test-session').CODEMAN_API_URL).toBe('https://127.0.0.1:3199');
expect(buildShellEnv('test-session').CODEMAN_API_URL).toBe('https://127.0.0.1:3199');
});
});
// A hardcoded fallback was the wrong scheme on HTTPS installs. The key must be
// genuinely ABSENT when unset: present-with-undefined would serialize through
// node-pty as the literal string "CODEMAN_API_URL=undefined" (COD-115).
it('leaves the key absent (not undefined, not a fallback) when the server has not stamped one', async () => {
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
withApiUrl(undefined, () => {
for (const env of [buildClaudeEnv('test-session'), buildShellEnv('test-session')]) {
expect('CODEMAN_API_URL' in env).toBe(false);
expect(JSON.stringify(env)).not.toContain('localhost:3000');
}
});
});
});
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -247,14 +247,41 @@ describe('TmuxManager (unit)', () => {
}); });
describe('environment exports', () => { describe('environment exports', () => {
it('keeps COLORTERM unset for OpenCode sessions', () => { const callBuildEnvExports = (mode: string) =>
const exports = ( (
manager as unknown as { manager as unknown as {
buildEnvExports(sessionId: string, muxName: string, mode: string): string[]; buildEnvExports(sessionId: string, muxName: string, mode: string): string[];
} }
).buildEnvExports('session-1', 'codeman-abc12345', 'opencode'); ).buildEnvExports('session-1', 'codeman-abc12345', mode);
expect(exports).toContain('unset COLORTERM'); it('keeps COLORTERM unset for OpenCode sessions', () => {
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
});
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
const original = process.env.CODEMAN_API_URL;
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
try {
expect(callBuildEnvExports('claude')).toContain('export CODEMAN_API_URL=https://127.0.0.1:3199');
} finally {
if (original === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = original;
}
});
// A hardcoded fallback exported the wrong scheme on HTTPS installs; unset must
// stay unset so in-session guards fail closed instead of curling a bad URL.
it('exports no CODEMAN_API_URL at all when the server has not stamped one', () => {
const original = process.env.CODEMAN_API_URL;
delete process.env.CODEMAN_API_URL;
try {
const exports = callBuildEnvExports('claude');
expect(exports.some((line) => line.startsWith('export CODEMAN_API_URL'))).toBe(false);
expect(exports.join(' ')).not.toContain('localhost:3000');
} finally {
if (original === undefined) delete process.env.CODEMAN_API_URL;
else process.env.CODEMAN_API_URL = original;
}
}); });
}); });