From 477e73039c957c04b1bd5c8733001d33ab23aab3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 9 Aug 2026 11:49:29 +0200 Subject: [PATCH] fix(skill): match shift+tab for readiness, portable ANSI strip, endpoint gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- skills/codeman/SKILL.md | 65 ++++++++++++++++++++++----- skills/codeman/reference/endpoints.md | 53 +++++++++++++++++++--- skills/codeman/reference/recipes.md | 33 +++++++++++--- 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index f4d3a995..d5cb38fc 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -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. - **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. + **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 `shell`/`opencode`/`codex`/`gemini`/`antigravity`, requesting them explicitly is a 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), 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 +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 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 # 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 # worker). The death check is wait?until=exit, below. 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. +# stage 1-3: `shift+tab` is the composer's status bar in EVERY permission mode (see the +# 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" \ - --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 # composer never appeared → the trust dialog is probably still up; accept it once 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)) 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="; } + --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000') +fi +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 ``` @@ -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): ```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' \ - | 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 diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index c62c6f43..5fb915ca 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -13,6 +13,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 | +| `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 | | `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 | @@ -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 "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 | 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, 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 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 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: ```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): @@ -83,6 +107,12 @@ do want a worker in an existing checkout. confirmation at all. - `input` must be single-line (newlines are stripped). To send a bare Enter (confirm 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 once. Increment `seq` per new input. @@ -94,6 +124,13 @@ Three bounded long-polls. Shared semantics: `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. +- ⚠️ 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. - `.data.status` (post-wait `SessionStatus`) and `.data.limitPaused` ride along. `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 | |-------|---------|-------| | `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 | ⚠️ 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 | | `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 | +| `timeout` | 60000 | same clamp, same positive-integer rule | 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 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, + space-free token** (`trust`, `shift+tab`). 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 @@ -185,7 +222,7 @@ around the match, blank runs collapsed — the snippet is often all you need to | Field | Notes | |-------|-------| | `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 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 | | 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) | +| 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` 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 | diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 148ddd49..dd867578 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -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 — # the long budget belongs to stage 3, after the dialog is answered. # 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 [ "$("${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') + --data-urlencode 'match=shift+tab' --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') @@ -55,8 +62,21 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then 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=" + --data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000') +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 # 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-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 +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") 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 + | 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 # session's permission prompt fi