mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(skill): match shift+tab for readiness, portable ANSI strip, endpoint gaps
The readiness gate matched `bypass`, which is the status bar of ONE permission mode. `buildPermissionArgs()` also spawns `--permission-mode auto`, `--allowedTools` and plain `normal`, and the mode is not exposed on `GET /api/v1/sessions/:id`, so an agent cannot know which token to expect. A non-default worker was therefore reported broken after burning the whole ladder. Measured one pane per mode against claude-cli 2.1.226: --dangerously-skip-permissions -> "bypass permissions on" --permission-mode auto -> "auto mode on" --allowedTools Read,Grep -> "don't ask on" (none, normal) -> "don't ask on" --permission-mode plan -> "plan mode on" Every one ends `(shift+tab to cycle)`, so `shift+tab` is the single space-free token that means "the composer is up" in every mode, and it is what the ladder matches now. Verified live end to end on a virgin case: stage 1 misses while the trust dialog is up, stage 2 accepts it, stage 3 matches in 623ms. ⚠️ `shift+tab` contains a `+`, so it only works through `--data-urlencode`. In a hand-built query the `+` decodes to a space and the server searches for `shift tab`, which never appears; the response echoes `match: "shift tab"`, which is how to spot it. Measured both ways. The stage-4 fallback (make the worker echo a split token, proving readiness by answering rather than by chrome) stays as the last resort, and is now also verified live: it matched in 2.5s, with the token surviving the space-less TUI intact. Also portable ANSI stripping: the read pipelines used `sed 's/\x1b...'`, and BSD sed (the macOS default) has no `\xHH` escape, so on macOS the strip silently removed nothing and handed the agent raw ANSI. They now build a real ESC with `printf`. And endpoints.md gaps: the `FORBIDDEN` 403 row and which auth responses are plain text rather than the JSON envelope, the input size cap, the undocumented `killMux` parameter on DELETE, and the fact that zero/negative/non-integer timeouts are rejected with a 400 rather than clamped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+55
-10
@@ -158,7 +158,9 @@ You are yourself a session on this server, and the API has **no undo**.
|
|||||||
`{"success":false,"error","errorCode"}`. Read `.data`. Use `/api/v1/*` paths.
|
`{"success":false,"error","errorCode"}`. Read `.data`. Use `/api/v1/*` paths.
|
||||||
- **A wait timeout is HTTP 200**, `{wait:{timedOut:true,signal:null}}` — not an error.
|
- **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
|
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.
|
**clamped** (ceiling 600 s): read back `wait.timeoutMs` for what was applied. The
|
||||||
|
clamp covers positive integers only: `0`, a negative, a fraction or `30s` is a 400,
|
||||||
|
so round any computed remainder and drop it entirely rather than sending zero.
|
||||||
- **`stop` and `blocked` fire for `claude` sessions only** (Claude Code hooks). On
|
- **`stop` and `blocked` fire for `claude` sessions only** (Claude Code hooks). On
|
||||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`, requesting them explicitly is a
|
||||||
400 — and lifecycle transitions there are coarse (a short shell command may emit
|
400 — and lifecycle transitions there are coarse (a short shell command may emit
|
||||||
@@ -191,10 +193,35 @@ contains `❯` too — observed live). Codeman *can* auto-accept that dialog its
|
|||||||
the accept rides a stream match that misses on some runs (both outcomes seen live),
|
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 —
|
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
|
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
|
composer). Stage 1 is short on purpose: an already-trusted case matches `shift+tab` in
|
||||||
under a second, while a **virgin case can never pass stage 1** (the dialog is up, so
|
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
|
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:
|
budget belongs to stage 3, after the dialog is answered.
|
||||||
|
|
||||||
|
⚠️ **Match `shift+tab`, never `bypass`.** The permission mode is a server-side setting
|
||||||
|
(`claudeMode`) that is **not** exposed on `GET /api/v1/sessions/:id`, so you cannot read
|
||||||
|
which mode a worker runs. `bypass permissions on` is only the DEFAULT mode's statusline.
|
||||||
|
Measured against claude-cli 2.1.226, one pane per mode:
|
||||||
|
|
||||||
|
| how Codeman spawned it | statusline reads | `shift+tab` | `bypass` |
|
||||||
|
|------------------------|------------------|-------------|----------|
|
||||||
|
| `--dangerously-skip-permissions` (default) | `bypass permissions on` | yes | yes |
|
||||||
|
| `--permission-mode auto` | `auto mode on` | yes | no |
|
||||||
|
| `--allowedTools …` | `don't ask on` | yes | no |
|
||||||
|
| neither (`normal`) | `don't ask on` | yes | no |
|
||||||
|
|
||||||
|
Every mode ends its status bar with `(shift+tab to cycle)`, so `shift+tab` is the one
|
||||||
|
token that means "the composer is up" regardless of mode, and it is space-free, which is
|
||||||
|
what makes it survive the TUI stream. Matching `bypass` instead reports a perfectly
|
||||||
|
healthy non-default worker as broken after burning the full ladder.
|
||||||
|
|
||||||
|
⚠️ **`shift+tab` contains a `+`, so it MUST go through `--data-urlencode`.** In a
|
||||||
|
hand-built query the `+` decodes to a space and the server searches for `shift tab`,
|
||||||
|
which never appears (measured: `matched:false`, and the response echoes back
|
||||||
|
`match: "shift tab"`, which is how you spot it).
|
||||||
|
|
||||||
|
Stage 4 stays as the last resort for the case where even that misses: a worker that
|
||||||
|
answers a trivial prompt **is** ready, whatever its statusline reads.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ALWAYS check .success: on failure `.data.sessionId` is null, jq -r prints the string
|
# ALWAYS check .success: on failure `.data.sessionId` is null, jq -r prints the string
|
||||||
@@ -216,10 +243,11 @@ done
|
|||||||
# its pane keeps status "idle" and a pid (the local tmux attach client, not the
|
# its pane keeps status "idle" and a pid (the local tmux attach client, not the
|
||||||
# worker). The death check is wait?until=exit, below.
|
# worker). The death check is wait?until=exit, below.
|
||||||
SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$
|
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
|
# stage 1-3: `shift+tab` is the composer's status bar in EVERY permission mode (see the
|
||||||
# spawns claude in bypass mode. Single-token matches only: TUI text is space-less.
|
# table above), so this works whatever `claudeMode` the server runs. Single-token
|
||||||
|
# matches only: TUI text is space-less. The `+` needs --data-urlencode.
|
||||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
||||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||||
# composer never appeared → the trust dialog is probably still up; accept it once
|
# composer never appeared → the trust dialog is probably still up; accept it once
|
||||||
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
@@ -230,9 +258,23 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
|||||||
SEQ=$((SEQ+1))
|
SEQ=$((SEQ+1))
|
||||||
fi
|
fi
|
||||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
|
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
|
||||||
jq -e '.data.wait.matched' <<<"$R" >/dev/null || \
|
fi
|
||||||
{ echo "worker $SID never became ready; inspect terminal?tail="; }
|
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||||
|
# stage 4, last resort: the composer never appeared at all. A miss is still not proof
|
||||||
|
# of a broken worker, and answering is proof that it works. Split the token (your keystrokes echo
|
||||||
|
# into the stream) and keep it unique per call. This costs the worker one turn, so
|
||||||
|
# it runs only after the fast path missed. It must stay AFTER stage 2, which is the
|
||||||
|
# only thing that clears the trust dialog: free text plus \r into a dialog still up
|
||||||
|
# answers it blind, which is the same footgun as the up-front Enter.
|
||||||
|
TOK="${RANDOM}_$$"
|
||||||
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
|
||||||
|
SEQ=$((SEQ+1))
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
|
--data-urlencode "match=READY_$TOK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000' \
|
||||||
|
| jq -e '.data.wait.matched' >/dev/null \
|
||||||
|
|| echo "worker $SID never became ready; inspect terminal?tail="
|
||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -313,8 +355,11 @@ why the loop above is bounded rather than open-ended. Fall back to the terminal
|
|||||||
(`textOutput` in `GET .../output` stays empty for interactive sessions; don't use it):
|
(`textOutput` in `GET .../output` stays empty for interactive sessions; don't use it):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# \x1b is a GNU-sed extension: BSD sed (macOS) matches it as a literal "x1b", so the
|
||||||
|
# same one-liner strips NOTHING there and hands you raw ANSI. Feed sed a real ESC.
|
||||||
|
ESC=$(printf '\033')
|
||||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=3000" | jq -r '.data.terminalBuffer' \
|
"${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
|
| sed -e "s/${ESC}\[[0-9;?]*[a-zA-Z]//g" -e "s/${ESC}([B0]//g" | grep -v '^[[:space:]]*$' | tail -30
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ Do not use that pipeline to read a **claude/codex** answer. A full-screen TUI draws
|
⚠️ Do not use that pipeline to read a **claude/codex** answer. A full-screen TUI draws
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Every JSON response: `{"success":true,"data":…}` or
|
|||||||
|-------------|------|---------|
|
|-------------|------|---------|
|
||||||
| `INVALID_INPUT` | 400 | malformed request; the message names the bad field |
|
| `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 |
|
| `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 |
|
||||||
|
| `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) |
|
||||||
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
|
| `NOT_FOUND` | 404 | no such session, or one this caller does not own |
|
||||||
| `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 |
|
| `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 |
|
| `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state |
|
||||||
@@ -23,6 +24,15 @@ Every JSON response: `{"success":true,"data":…}` or
|
|||||||
`SESSION_BUSY` vs `RATE_LIMITED` on the wait endpoints is deliberate: the first means
|
`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.
|
"too many waiters on *this* session", the second means the *pool* is full.
|
||||||
|
|
||||||
|
⚠️ **The guards that run before any handler answer in PLAIN TEXT, not this envelope**,
|
||||||
|
so `jq` reports a parse error and `.errorCode` is simply absent. All of them:
|
||||||
|
`401 Unauthorized` (Basic auth, carries `WWW-Authenticate`), `401 Unauthorized: hook
|
||||||
|
secret required`, `403 Forbidden: host not allowed` (Host allowlist), `403 Forbidden:
|
||||||
|
cross-site request blocked` (Origin/CSRF guard), and the auth rate limiter's
|
||||||
|
`429 Too Many Requests` (with `Retry-After`; distinct from the JSON `RATE_LIMITED`
|
||||||
|
above, which is the waiter pool). When a call returns something `jq` cannot parse,
|
||||||
|
read the status with `-w '%{http_code}'` and the raw body before assuming a bug.
|
||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
| Task | Call |
|
| Task | Call |
|
||||||
@@ -38,7 +48,17 @@ Every JSON response: `{"success":true,"data":…}` or
|
|||||||
| background agents, one session | `GET /api/v1/sessions/:id/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) |
|
| background agents, global list | `GET /api/v1/subagents` (admin-only in multi-user mode) |
|
||||||
| server status / version | `GET /api/v1/status` → `.data.version` |
|
| server status / version | `GET /api/v1/status` → `.data.version` |
|
||||||
| 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 |
|
| 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. Answers `{"success":true,"data":{}}`: an **empty** body is the success signal, there is nothing to read back |
|
||||||
|
|
||||||
|
`DELETE /api/v1/sessions/:id` takes one undocumented query parameter, `killMux`, and
|
||||||
|
it defaults to `true` (anything other than the exact string `false` means kill). With
|
||||||
|
`?killMux=false` the call **detaches instead of killing**: the tmux session and the
|
||||||
|
agent inside it keep running, the session drops out of `GET /api/v1/sessions` so it
|
||||||
|
looks deleted, and it is deliberately left in persisted state for recovery (the
|
||||||
|
lifecycle log records `detached`, not `deleted`). That is the wrong tool for agent
|
||||||
|
cleanup: your worker keeps burning tokens where neither you nor the user can see it,
|
||||||
|
and the list you would check to confirm cleanup shows it gone. Delete plainly, and let
|
||||||
|
`killMux` default.
|
||||||
|
|
||||||
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
|
⚠️ `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
|
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
|
||||||
@@ -47,7 +67,11 @@ JSON-stream path). Verified empty on live claude and shell sessions. Use
|
|||||||
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
… | jq -r '.data.terminalBuffer' | sed -e 's/\x1b\[[0-9;?]*[a-zA-Z]//g' -e 's/\x1b([B0]//g'
|
# `\x1b` is a GNU-sed extension. BSD sed (macOS, the default there) reads it as a
|
||||||
|
# literal "x1b", matches nothing, and hands back raw ANSI, silently. Feed sed a real
|
||||||
|
# ESC byte instead; that form works on GNU and BSD alike.
|
||||||
|
ESC=$(printf '\033')
|
||||||
|
… | jq -r '.data.terminalBuffer' | sed -e "s/${ESC}\[[0-9;?]*[a-zA-Z]//g" -e "s/${ESC}([B0]//g"
|
||||||
```
|
```
|
||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
@@ -83,6 +107,12 @@ do want a worker in an existing checkout.
|
|||||||
confirmation at all.
|
confirmation at all.
|
||||||
- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
|
- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm
|
||||||
a dialog), send `{"input":"\r"}`.
|
a dialog), send `{"input":"\r"}`.
|
||||||
|
- `input` is capped at **100 000 characters**; one character over is a 400
|
||||||
|
`INVALID_INPUT` and **nothing is typed** (the schema rejects the whole body, so it
|
||||||
|
is not a truncation). Since the value is one line anyway, a prompt that big means
|
||||||
|
you are pasting a file into the composer: write it to disk in the worker's case
|
||||||
|
directory and send a path instead. `clientId` is capped at 128 characters on the
|
||||||
|
same terms.
|
||||||
- `clientId`+`seq` give exactly-once delivery: the server applies each pair at most
|
- `clientId`+`seq` give exactly-once delivery: the server applies each pair at most
|
||||||
once. Increment `seq` per new input.
|
once. Increment `seq` per new input.
|
||||||
|
|
||||||
@@ -94,6 +124,13 @@ Three bounded long-polls. Shared semantics:
|
|||||||
`tailscale serve` / cloudflared cut idle connections.
|
`tailscale serve` / cloudflared cut idle connections.
|
||||||
- Timeouts are **clamped** to `[1000, 600000]` ms (operator-tunable); the applied
|
- Timeouts are **clamped** to `[1000, 600000]` ms (operator-tunable); the applied
|
||||||
value is echoed as `wait.timeoutMs` — read it back, never assume.
|
value is echoed as `wait.timeoutMs` — read it back, never assume.
|
||||||
|
- ⚠️ Clamping only covers **positive integers**. `timeout=0`, a negative value, a
|
||||||
|
fraction (`timeout=1500.5`) and anything non-numeric (`timeout=30s`) are rejected by
|
||||||
|
the schema as a 400 `INVALID_INPUT` naming the field, not silently clamped up to
|
||||||
|
the floor. Omit the parameter to take the 60 000 ms default; never send a computed
|
||||||
|
remainder without rounding it and checking it is still above zero. Same rule for
|
||||||
|
`waitTimeout` in the input body, where the value must additionally be a JSON number
|
||||||
|
(a quoted `"60000"` is a 400).
|
||||||
- All three nest the result under `.data.wait`, same shape, so one helper parses all.
|
- 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.
|
- `.data.status` (post-wait `SessionStatus`) and `.data.limitPaused` ride along.
|
||||||
`limitPaused:true` means the session is paused on a usage limit and will emit
|
`limitPaused:true` means the session is paused on a usage limit and will emit
|
||||||
@@ -136,7 +173,7 @@ worker that finishes before its gather is unobservable (see recipes.md Flow 3b).
|
|||||||
| Param | Default | Notes |
|
| Param | Default | Notes |
|
||||||
|-------|---------|-------|
|
|-------|---------|-------|
|
||||||
| `until` | `stop,idle,exit` | comma list; unknown token → 400 naming it |
|
| `until` | `stop,idle,exit` | comma list; unknown token → 400 naming it |
|
||||||
| `timeout` | 60000 | ms, clamped; applied value echoed as `wait.timeoutMs` |
|
| `timeout` | 60000 | ms, positive integer only (0/negative/fractional = 400); clamped, applied value echoed as `wait.timeoutMs` |
|
||||||
| `fresh` | `0` | `1` requires an actual *transition*, ignoring the state at call time |
|
| `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`
|
⚠️ A session whose PTY has not spawned (`pid:null`) or has exited counts as `exit`
|
||||||
@@ -152,7 +189,7 @@ SKILL.md, not this endpoint.
|
|||||||
| `match` | required | literal substring, 1–200 chars, ANSI-stripped; chunk-straddling matches found; **no regex** — a `regex=` param is a 400 |
|
| `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 |
|
| `nocase` | `0` | case-insensitive compare; snippet keeps original casing |
|
||||||
| `from` | `now` | `buffer` scans the tail (~256 KB) of existing output first |
|
| `from` | `now` | `buffer` scans the tail (~256 KB) of existing output first |
|
||||||
| `timeout` | 60000 | same clamp |
|
| `timeout` | 60000 | same clamp, same positive-integer rule |
|
||||||
|
|
||||||
Four traps, all observed live:
|
Four traps, all observed live:
|
||||||
|
|
||||||
@@ -173,7 +210,7 @@ Four traps, all observed live:
|
|||||||
spaced phrase. Whether a given phrase keeps its spaces depends on how the TUI
|
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
|
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
|
multi-word matches against TUI screens as unreliable and match a **single
|
||||||
space-free token** (`trust`, `bypass`). Plain command output (shell workers,
|
space-free token** (`trust`, `shift+tab`). Plain command output (shell workers,
|
||||||
`echo` lines) keeps real spaces and multi-word matches work there.
|
`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
|
Build the query with `-G --data-urlencode` (a `+` in a hand-built query decodes to a
|
||||||
@@ -185,7 +222,7 @@ around the match, blank runs collapsed — the snippet is often all you need to
|
|||||||
| Field | Notes |
|
| Field | Notes |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| `wait` | `true` (default signal set) or the same comma grammar as `until`; absent = historical fire-and-forget |
|
| `wait` | `true` (default signal set) or the same comma grammar as `until`; absent = historical fire-and-forget |
|
||||||
| `waitTimeout` | ms, same clamp |
|
| `waitTimeout` | ms, same clamp; a JSON number, positive integer (`"60000"` is a 400) |
|
||||||
|
|
||||||
Registers the waiter **before** typing, which closes the race where send-then-wait
|
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`
|
sees the previous turn's idle state and returns instantly. Response adds `delivered`
|
||||||
@@ -222,7 +259,9 @@ whose prompt was never submitted (missing `\r`) produces the same
|
|||||||
| 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 |
|
| 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 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` |
|
| 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) |
|
| 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 `shift+tab` first, accept the dialog only as the bounded fallback) |
|
||||||
|
| readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the mode is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). ⚠️ It must go through `--data-urlencode`, or the `+` decodes to a space and you silently search for `shift tab`. Expect `blocked` signals mid-turn on the non-default modes |
|
||||||
|
| ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above |
|
||||||
| `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` 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` |
|
| `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 |
|
| 409 `SESSION_BUSY` on a wait | too many concurrent waiters on that session (cap 16 combined); reuse one wait per worker |
|
||||||
|
|||||||
@@ -39,13 +39,20 @@ SEQ=1 # $CID is the fixed literal from §0; never rebuild it from
|
|||||||
# virgin case can never pass it (the dialog is up) and always pays it in full —
|
# 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.
|
# the long budget belongs to stage 3, after the dialog is answered.
|
||||||
# Single-token matches only: TUI text is space-less in the stream.
|
# Single-token matches only: TUI text is space-less in the stream.
|
||||||
|
# ⚠️ `bypass` is the statusline of ONE permission mode (the default one Codeman
|
||||||
|
# spawns). The server's `claudeMode` setting also has auto/allowedTools/normal
|
||||||
|
# spawns whose statusline differs, and the mode is not exposed on GET
|
||||||
|
# /api/v1/sessions/:id. `shift+tab` is the one token EVERY mode's status bar ends
|
||||||
|
# with ('(shift+tab to cycle)'), measured per mode, so match that and not `bypass`.
|
||||||
|
# The `+` needs --data-urlencode or it decodes to a space. Stage 4 remains the last
|
||||||
|
# resort: proving readiness by making the worker answer rather than by chrome.
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
[ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ] && break; sleep 1
|
||||||
done
|
done
|
||||||
# (pid != null proves startup only — a worker that later dies inside its pane keeps
|
# (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.)
|
# status "idle" and a pid. The death check is wait?until=exit.)
|
||||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
|
||||||
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||||
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
|
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
|
||||||
@@ -55,8 +62,21 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
|||||||
SEQ=$((SEQ+1))
|
SEQ=$((SEQ+1))
|
||||||
fi
|
fi
|
||||||
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
|
--data-urlencode 'match=shift+tab' --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
|
||||||
|
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
|
||||||
|
# stage 4, mode-agnostic and bounded: answering a trivial prompt IS readiness.
|
||||||
|
# Costs the worker one turn, so it only runs when the fast marker missed. Split
|
||||||
|
# token (the typed line echoes into the stream) and unique per call. Must stay AFTER
|
||||||
|
# the dialog fallback: free text plus \r into a trust dialog still up answers it
|
||||||
|
# blind, the same footgun as an up-front Enter.
|
||||||
|
TOK="${RANDOM}_$$"
|
||||||
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
|
||||||
|
SEQ=$((SEQ+1))
|
||||||
|
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
|
||||||
|
--data-urlencode "match=READY_$TOK" --data-urlencode 'from=buffer' --data-urlencode 'timeout=60000' \
|
||||||
|
| jq -e '.data.wait.matched' >/dev/null || echo "worker $SID not ready; inspect terminal?tail="
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. send-and-wait, looping on the IDENTICAL request (tagged duplicate: no retype).
|
# 3. send-and-wait, looping on the IDENTICAL request (tagged duplicate: no retype).
|
||||||
@@ -244,13 +264,16 @@ the worker remembering to print a token.
|
|||||||
|
|
||||||
Claude workers can block on a permission dialog. `blocked` is a wait signal
|
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
|
(claude-mode only), so watch for it and surface the question to the user instead of
|
||||||
guessing an answer:
|
guessing an answer. Expect it routinely on a server whose `claudeMode` is not the
|
||||||
|
default bypass one (the same setting that decides whether the readiness marker in
|
||||||
|
Flow 1 ever appears):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
ESC=$(printf '\033') # \x1b is GNU-sed only; BSD sed (macOS) would strip nothing
|
||||||
R=$("${CURL[@]}" "$API/api/v1/sessions/$SID/wait?until=stop,blocked,exit&timeout=60000")
|
R=$("${CURL[@]}" "$API/api/v1/sessions/$SID/wait?until=stop,blocked,exit&timeout=60000")
|
||||||
if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
|
if [ "$(jq -r '.data.wait.signal' <<<"$R")" = blocked ]; then
|
||||||
"${CURL[@]}" "$API/api/v1/sessions/$SID/terminal?tail=2000" | jq -r '.data.terminalBuffer' \
|
"${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
|
| sed -e "s/${ESC}\[[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
|
# show this to the user and ask how to answer; do NOT auto-confirm another
|
||||||
# session's permission prompt
|
# session's permission prompt
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user