mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
feat(skill): add the agent-skill install layer and harden the packaged skill
Ship `skills/codeman` as an installable Claude Code skill rather than a repo-only reference, and fix six defects found while verifying it live. Install layer: - `codeman skill install [--case <name>]` / `codeman skill uninstall`. Case names resolve through linked-cases.json first, mirroring the server's resolveCasePath(), so a case linked in from outside ~/codeman-cases no longer fails with "Case not found". - applyAgentSkill() / installAgentSkillInto() / removeAgentSkillFrom() in hooks-config.ts. Copies are marker-owned, so an unmarked user-authored skill is never touched, and a symlinked skill dir is refused (this repo's own .claude/skills/codeman is a symlink to the source). - Synced `agentSkillEnabled` setting, default OFF: schemas.ts, ports/config-port.ts, server.ts, session-routes.ts (add-only injection on Claude session create and quick-start), plus the App Settings toggle. Skill content fixes, each reproduced before and after: - Fail-closed `delete_session` replaces `is_self ... || curl -X DELETE`. Shell state does not survive between agent tool calls, and an undefined is_self exited 127, firing the `||` branch and deleting the caller's own session with the one guard bypassed. The request now lives inside the guard, so a lost preamble deletes nothing. - clientId is a fixed literal instead of `agent-$$`. The pid changes per tool call, so the documented resend-identical-request loop stopped being a duplicate and retyped the prompt, submitting the turn twice. - `last-response` is now the documented read path for claude and codex workers. It returns clean transcript text; the terminal scrape it replaces returns a wall of TUI repaint noise. Its transcript flush lags the stop signal, so the recipes poll it rather than reading once. - quick-start examples branch on `.success`. Previously a failed spawn yielded the literal session id "null" and burned the whole readiness budget before reporting jq noise instead of the cause. - Documented that turning `agentSkillEnabled` off sweeps nothing, and corrected the hooks-config comment that claimed a toggle-off sweep exists. Per-case cleanup is `codeman skill uninstall --case <name>`. - Documented that SESSION_BUSY means the 50-session cap on quick-start, and that caseName resolves linked cases, so a generic name can land a worker in a real repo. Tests: test/agent-skill.test.ts covers install, refresh, idempotence, marker ownership and symlink refusal against the real packaged source; test/quick-start.test.ts covers injection behind the setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+94
-24
@@ -17,7 +17,24 @@ 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
|
||||
## 0. Guard, and the one thing that breaks every recipe below
|
||||
|
||||
⚠️ **Your shell state does not survive between tool calls.** Each Bash call starts a
|
||||
fresh shell, so `$API`, `$SELF`, the `CURL` array and `delete_session` are all gone by
|
||||
the next call, and `$$` is a different pid. Three consequences, all of which have
|
||||
teeth:
|
||||
|
||||
- **Re-run this entire preamble at the top of every Bash call that touches the API.**
|
||||
Running it once and assuming it stuck is the single most likely way to break a run.
|
||||
- **Never re-paste only half of it.** The delete guard below is written so that a
|
||||
missing definition deletes nothing, but that only holds if you never hand-roll a
|
||||
`DELETE` of your own.
|
||||
- **Never put `$$` in a `clientId`.** It changes per call, so the "resend the identical
|
||||
request" loop in §3 would stop being a duplicate and would **retype the prompt**,
|
||||
submitting the turn twice. Use a fixed literal (`codeman-agent-1` below).
|
||||
|
||||
Only real environment variables (`CODEMAN_*`) survive, which is why this preamble
|
||||
rebuilds everything else from them.
|
||||
|
||||
```bash
|
||||
test "${CODEMAN_MUX:-}" = 1 || { echo "Not inside a Codeman-managed session; refusing to act."; exit 1; }
|
||||
@@ -39,13 +56,35 @@ if [ -z "${CODEMAN_PASSWORD:-}" ]; then # stock installs: install.sh puts it
|
||||
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)
|
||||
# install.sh backslash-escapes " and \ in the unit value; undo it or a password
|
||||
# containing either recovers wrong and auth fails.
|
||||
CODEMAN_PASSWORD=$(sed -n 's/^Environment="CODEMAN_PASSWORD=\(.*\)"$/\1/p' "$UNIT" | head -1 | sed 's/\\\(["\\]\)/\1/g')
|
||||
elif [ -f "$PLIST" ]; then
|
||||
CODEMAN_PASSWORD=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$PLIST" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p')
|
||||
# install.sh XML-escapes the plist value; undo it (& LAST, mirroring escape order).
|
||||
CODEMAN_PASSWORD=$(awk '/<key>CODEMAN_PASSWORD<\/key>/{getline; print}' "$PLIST" | sed -n 's/.*<string>\(.*\)<\/string>.*/\1/p' \
|
||||
| sed -e 's/</</g' -e 's/>/>/g' -e 's/&/\&/g')
|
||||
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)
|
||||
|
||||
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
|
||||
# `is_self "$SID" || curl -X DELETE ...` shape failed OPEN, because an undefined
|
||||
# is_self exits 127 and the `||` branch then ran the delete completely unguarded.
|
||||
# Undefined delete_session is "command not found", which deletes nothing.
|
||||
delete_session() {
|
||||
local id="${1:-}"
|
||||
[ -n "$id" ] || { echo "refusing: empty session id"; return 1; }
|
||||
[ "${#SELF}" -ge 8 ] || { echo "refusing: \$SELF unset or too short to prove this is not me"; return 1; }
|
||||
# ids appear in full AND 8-char form (Docker exports a truncated $SELF; mux names and
|
||||
# UI surfaces carry 8-char ids), so compare by prefix in BOTH directions. Equality or
|
||||
# a one-directional check each miss a real combination, and the miss deletes you.
|
||||
case "$id" in "$SELF"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||
case "$SELF" in "$id"*) echo "refusing: $id is me"; return 1 ;; esac
|
||||
"${CURL[@]}" -X DELETE "$API/api/v1/sessions/$id"
|
||||
}
|
||||
|
||||
CID=codeman-agent-1 # FIXED literal, never "agent-$$" (see §0)
|
||||
```
|
||||
|
||||
- If `CODEMAN_MUX` is not `1`, **stop and say so**. Do not guess an API URL; a server
|
||||
@@ -67,20 +106,16 @@ CURL=(curl -sk "${AUTH[@]}") # -k: harmless on http, required on https (self-s
|
||||
|
||||
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.**
|
||||
- **Never act on your own session, and know that `delete_session` 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.
|
||||
dies silently (verified live). **Always delete through `delete_session "$SID"` from
|
||||
§0; never write a bare `curl -X DELETE` and never reintroduce the
|
||||
`is_self … || curl -X DELETE …` shape.** That older form failed open: with the
|
||||
function undefined (a half-re-pasted preamble, see §0) bash returns 127, the `||`
|
||||
branch fires, and the delete runs with no self-check at all. Wrapping the request
|
||||
inside the guard is what makes a lost preamble delete nothing instead of deleting
|
||||
you. Apply the same prefix-both-directions reasoning before any kill, respawn, or
|
||||
input call you write by hand.
|
||||
- **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
|
||||
@@ -162,15 +197,25 @@ the composer is not) and always pays it in full before the fallback runs — the
|
||||
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')
|
||||
# ALWAYS check .success: on failure `.data.sessionId` is null, jq -r prints the string
|
||||
# "null", and the flow below then burns its full readiness budget against
|
||||
# /api/v1/sessions/null before reporting jq noise instead of the actual cause.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-1","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
if [ -z "$SID" ]; then
|
||||
# SESSION_BUSY here is the 50-session cap, not the waiter cap; FORBIDDEN/CONFLICT/
|
||||
# OPERATION_FAILED/INVALID_INPUT are the others. None are retryable in a loop.
|
||||
jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed; stopping."
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$
|
||||
# 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" \
|
||||
@@ -245,15 +290,40 @@ SEQ=$((SEQ+1))
|
||||
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):
|
||||
**Read a worker's answer.** For `claude` and `codex` workers this is the read path:
|
||||
`last-response` returns the agent's final message as clean text, taken from the
|
||||
transcript rather than the screen, so it carries none of the TUI's box-drawing or
|
||||
repaint noise.
|
||||
|
||||
```bash
|
||||
for _ in $(seq 1 10); do # the transcript write LAGS the stop signal
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
```
|
||||
|
||||
`.data` is `{text, timestamp}`. ⚠️ **Poll it, do not read it once.** `text` is written
|
||||
from the transcript file, which is flushed slightly *after* the `stop` hook fires, so a
|
||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, verified live), which is
|
||||
why the loop above is bounded rather than open-ended. Fall back to the terminal buffer there, 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.
|
||||
⚠️ Do not use that pipeline to read a **claude/codex** answer. A full-screen TUI draws
|
||||
with cursor moves, so the stripped buffer is largely one long line: `tail -30` has
|
||||
almost nothing to split on and you get a wall of repaint noise with the answer buried
|
||||
in it (verified live, side by side with `last-response` returning the exact prose).
|
||||
The terminal buffer is for *diagnosis* (is my prompt sitting unsubmitted?), not for
|
||||
reading answers. 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
|
||||
@@ -262,10 +332,10 @@ 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:
|
||||
**Clean up** — only ids you created, one at a time, always through the §0 helper:
|
||||
|
||||
```bash
|
||||
is_self "$SID" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
Everything else (endpoint tables, per-mode signal table, error codes, capacity
|
||||
|
||||
@@ -14,7 +14,7 @@ Every JSON response: `{"success":true,"data":…}` or
|
||||
| `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 |
|
||||
| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: the 50-session cap is full, so clean up before starting more |
|
||||
| `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 |
|
||||
@@ -32,16 +32,19 @@ Every JSON response: `{"success":true,"data":…}` or
|
||||
| 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` |
|
||||
| **read a worker's answer** (claude/codex) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}` — clean transcript text, no TUI noise. ⚠️ **Poll it**: the transcript flush lags the `stop` signal, so a read taken the instant send-and-wait returns is `""` (verified live). Also `""` before the first completed turn, and always `""` for `shell`/`opencode`/`gemini`/`antigravity` (no transcript) |
|
||||
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer` — for *diagnosis* (unsubmitted prompt?), not for reading answers |
|
||||
| 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` |
|
||||
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
|
||||
| background agents, global list | `GET /api/v1/subagents` (admin-only in multi-user mode) |
|
||||
| server status / version | `GET /api/v1/status` → `.data.version` |
|
||||
| delete one session (yours, `is_self`-checked) | `DELETE /api/v1/sessions/:id` |
|
||||
| delete one session (yours only, via `delete_session`) | `DELETE /api/v1/sessions/:id` — never call it bare; the fail-closed helper in SKILL.md §0 is the only self-protection that exists |
|
||||
|
||||
⚠️ `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:
|
||||
JSON-stream path). Verified empty on live claude and shell sessions. Use
|
||||
`last-response` for claude/codex answers; only fall back to `terminal?tail=` for
|
||||
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
||||
|
||||
```bash
|
||||
… | jq -r '.data.terminalBuffer' | sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g'
|
||||
@@ -53,6 +56,18 @@ JSON-stream path). Verified empty on live claude and shell sessions. Read
|
||||
`.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.
|
||||
|
||||
⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field
|
||||
is absent, `jq -r` prints the literal string `null`, and every later call then targets
|
||||
`/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise
|
||||
instead of the real cause. Failure modes here are `SESSION_BUSY` (the **50-session
|
||||
cap**, not the waiter cap), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and
|
||||
`INVALID_INPUT`; none of them are retryable in a loop.
|
||||
|
||||
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
|
||||
to match a case the user linked in lands in that **real repo**, not a fresh scratch
|
||||
directory. Pick distinctive scratch names, and use a linked name deliberately when you
|
||||
do want a worker in an existing checkout.
|
||||
|
||||
`POST /api/v1/sessions/:id/input` body:
|
||||
`{"input":"one line\r","useMux":true,"clientId":"agent-1","seq":1}` plus optionally
|
||||
`"wait"` / `"waitTimeout"` (below).
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# 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.
|
||||
Loaded on demand from the `codeman` skill. Every flow assumes the SKILL.md §0 preamble
|
||||
is in scope (`$API`, `$SELF`, `$CID`, `"${CURL[@]}"`, `delete_session`).
|
||||
|
||||
⚠️ **That preamble does not survive between tool calls**, so re-run it at the top of
|
||||
every Bash call that uses these flows, in full. Re-pasting only part of it is the
|
||||
failure mode the fail-closed `delete_session` exists to contain, and a `clientId` you
|
||||
rebuild from `$$` changes per call, which turns the duplicate-resend loop in Flow 1
|
||||
into a second typed prompt.
|
||||
|
||||
Track every session id you create; delete them (and only them) when done. 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
|
||||
|
||||
@@ -13,11 +20,15 @@ the turn to finish, read the answer, clean up. Verified live: the stop hook reso
|
||||
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')
|
||||
# 1. start (returns before the CLI inside is ready). ALWAYS check .success: on failure
|
||||
# .data.sessionId is null, jq -r yields the string "null", and every step below
|
||||
# then runs against /api/v1/sessions/null and reports jq noise, not the cause.
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"worker-tests","mode":"claude"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed"; exit 1; }
|
||||
CREATED+=("$SID") # the cleanup list
|
||||
CID="agent-$$"; SEQ=1
|
||||
SEQ=1 # $CID is the fixed literal from §0; never rebuild it from $$
|
||||
|
||||
# 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 ❯.
|
||||
@@ -84,13 +95,23 @@ case "$(jq -r '.data.wait.signal' <<<"$R")" in
|
||||
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
|
||||
# 5. read the answer. For a claude worker this is last-response: clean transcript text,
|
||||
# no TUI repaint noise. Do NOT scrape the terminal for this — a full-screen TUI
|
||||
# draws with cursor moves, so the stripped buffer is nearly one long line and the
|
||||
# answer arrives buried in redraw garbage.
|
||||
# POLL it: the transcript flush lags the stop signal, so a single read taken the
|
||||
# instant step 3 returned comes back "" even though the turn finished (verified live).
|
||||
for _ in $(seq 1 10); do
|
||||
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
|
||||
[ -n "$TXT" ] && break; sleep 1
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity, which have no transcript — use
|
||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||
|
||||
# 6. clean up — exact id, own list only, self-check
|
||||
is_self "$SID" || "${CURL[@]}" -X DELETE "$API/api/v1/sessions/$SID"
|
||||
# 6. clean up — exact id, own list only, through the fail-closed §0 helper
|
||||
delete_session "$SID"
|
||||
```
|
||||
|
||||
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
|
||||
@@ -104,8 +125,10 @@ live), so send-and-wait can burn its whole timeout. The reliable pattern is a sp
|
||||
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')
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"builder","mode":"shell"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "quick-start failed"; exit 1; }
|
||||
CREATED+=("$SID")
|
||||
for _ in $(seq 1 30); do
|
||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||
@@ -115,7 +138,7 @@ done
|
||||
# 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}'
|
||||
-d '{"input":"M=DONE; npm run build; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"codeman-build-1","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" \
|
||||
@@ -136,8 +159,10 @@ 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')
|
||||
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
|
||||
-d '{"caseName":"fan-'"$task"'","mode":"shell"}')
|
||||
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
|
||||
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; echo "$task: spawn failed"; continue; }
|
||||
WORKER[$task]=$SID; CREATED+=("$SID")
|
||||
done
|
||||
for task in "${!WORKER[@]}"; do
|
||||
@@ -147,7 +172,7 @@ for task in "${!WORKER[@]}"; do
|
||||
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}'
|
||||
-d '{"input":"M=DONE; npm run '"$task"'; echo ${M}_'"$N"' rc=$?\r","useMux":true,"clientId":"codeman-fan-'"$task"'","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
|
||||
@@ -171,8 +196,8 @@ 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}')
|
||||
local body; body=$(jq -n --arg p "$2" --argjson s "$3" --arg c "codeman-fan-$1" \
|
||||
'{input:($p+"\r"),useMux:true,clientId:$c,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"
|
||||
}
|
||||
@@ -200,7 +225,7 @@ 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}')
|
||||
--arg c "codeman-fan-$i" --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
|
||||
@@ -237,12 +262,17 @@ 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"
|
||||
delete_session "$id"
|
||||
done
|
||||
```
|
||||
|
||||
- Only ids from your own `CREATED` list. Never enumerate `/api/v1/sessions` and
|
||||
delete by pattern; other sessions belong to the user.
|
||||
- Always go through `delete_session`. It refuses an empty id, refuses when `$SELF` is
|
||||
unset or too short to prove the target is not you, and prefix-checks in both
|
||||
directions. A hand-written `curl -X DELETE`, or the old
|
||||
`is_self "$id" || curl -X DELETE …`, has none of that: an undefined `is_self` exits
|
||||
127 and the `||` branch deletes unguarded.
|
||||
- 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
|
||||
|
||||
Reference in New Issue
Block a user