mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user