mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
fix(input): make sure a prompt sent through the API actually leaves the composer
Claude Code 2.1.277 takes typed text the moment its composer paints but ignores Enter for the first 30 to 50 seconds after it (measured 2026-09-19 through the input route: an Enter at 28 s stranded the prompt, one at 51 s submitted it). The text+Enter pair `sendInput` sends 50 ms apart therefore left every programmatic prompt sitting unsent, and every waiter burned its timeout on a turn that never started. Server: `SubmitVerifier` (session-submit-verifier.ts), armed from `writeViaMux` for every mux write that carried a carriage return, reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the last composer line (the CLI's own prompt glyph) still holds the head of what was sent. An empty composer, other text, or no composer line at all ends it; a newer write replaces the schedule. Skill: `sendwait` gets the same loop (`_composer_text`, no-break space stripped by its bytes for BSD sed) for servers that predate this, and the preamble version moves to 1.30.1 so seeded agents pick up the fresh copy. SKILL.md's heredoc and the plugin mirror are regenerated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(input): make sure a prompt sent through the API actually leaves the composer. Claude Code 2.1.277 started ignoring Enter for the first 30 to 50 seconds after the composer paints while still accepting the typed text, so a prompt sent right after a session came up sat unsent in the pane and every waiter (send-and-wait, the agent skill, cron, the maintainer bot) burned its whole timeout on a turn that never started. The server now reads the pane after every programmatic write that carried Enter and presses Enter again, on a 2 to 60 second schedule, only while the composer verifiably still holds the text it sent; an empty composer, other text, or a pane with no composer at all ends it. The agent skill's `sendwait` gets the same loop for servers that predate this, and its preamble version moves to 1.30.1 so an already-seeded agent picks up the fresh copy.
|
||||||
@@ -128,7 +128,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`
|
- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted
|
||||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -150,6 +150,27 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
|
|||||||
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
||||||
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
||||||
}
|
}
|
||||||
|
# ---- the composer: is the prompt still sitting there, unsent? ----
|
||||||
|
# ⚠️ Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment the
|
||||||
|
# composer paints but IGNORES Enter for the first 30-50 seconds after it: the \r that
|
||||||
|
# Codeman sends 50 ms after the text and a lone nudge at 20 s both leave the prompt
|
||||||
|
# stranded, with `0 tokens`, while the wait burns its whole timeout. Measured through
|
||||||
|
# this very route: Enter at 28 s stranded, Enter at 51 s submitted. So sendwait READS
|
||||||
|
# the composer and keeps pressing Enter while the prompt is still there.
|
||||||
|
_composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "" once
|
||||||
|
# the prompt was taken, "?" when the pane shows no composer at all. The composer is
|
||||||
|
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
|
||||||
|
# up in the transcript, so only the last one says whether the text was taken.
|
||||||
|
local t
|
||||||
|
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
|
||||||
|
[ -n "$t" ] || { printf '?'; return 0; }
|
||||||
|
# Claude Code draws a NO-BREAK SPACE (U+00A0) after the glyph, which [:space:] does
|
||||||
|
# not cover, so it is stripped by its bytes, portably (BSD sed has no \xHH).
|
||||||
|
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
|
||||||
|
}
|
||||||
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
||||||
local sid="$1" k i=1
|
local sid="$1" k i=1
|
||||||
while [ "$i" -le 6 ]; do
|
while [ "$i" -le 6 ]; do
|
||||||
@@ -262,12 +283,15 @@ spawn_workers() {
|
|||||||
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
||||||
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
||||||
# deliberate duplicate, at the SAME number (§5.3).
|
# deliberate duplicate, at the SAME number (§5.3).
|
||||||
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
|
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
|
||||||
# typed prompt stranded on the composer while a long wait runs its whole timeout
|
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
|
||||||
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
# leaving the typed prompt stranded on the composer while a long wait runs its whole
|
||||||
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
|
||||||
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
# timeout the composer is READ (_composer_text), and while the prompt is still sitting
|
||||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
# there a bare \r goes out and a short re-wait follows, up to twelve times ten seconds
|
||||||
|
# apart. Each re-wait resends the ORIGINAL frame unchanged, which the server takes as a
|
||||||
|
# tagged duplicate: it re-waits without retyping (§5.3). An empty composer ends the
|
||||||
|
# loop, so a prompt that was taken is never nudged again. Trustworthy for a worker
|
||||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||||
@@ -275,7 +299,7 @@ spawn_workers() {
|
|||||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||||
# pane clearly finished means that profile, so switch that worker to markers.
|
# pane clearly finished means that profile, so switch that worker to markers.
|
||||||
sendwait() {
|
sendwait() {
|
||||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0
|
||||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||||
@@ -290,6 +314,17 @@ sendwait() {
|
|||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$body")
|
-H 'Content-Type: application/json' --data-binary "$body")
|
||||||
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||||
|
# The prompt's head with whitespace removed, matched literally (the "$head"
|
||||||
|
# quoting inside ${c#...} keeps a * or ? in the prompt from acting as a glob).
|
||||||
|
head=$(printf '%s' "$p" | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" | head -c 24)
|
||||||
|
while [ "$n" -lt 12 ]; do
|
||||||
|
c=$(_composer_text "$sid")
|
||||||
|
if [ "$c" = '?' ]; then
|
||||||
|
[ "$n" -eq 0 ] || break # unreadable pane: one Enter, then trust it
|
||||||
|
elif [ -z "$head" ] || [ "${c#"$head"}" = "$c" ]; then
|
||||||
|
break # composer empty (taken) or holding other text
|
||||||
|
fi
|
||||||
|
n=$((n+1))
|
||||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||||
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||||
@@ -297,10 +332,18 @@ sendwait() {
|
|||||||
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
||||||
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
||||||
# as an undelivered one and keeps a finished worker forever.
|
# as an undelivered one and keeps a finished worker forever.
|
||||||
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=10000' <<<"$body")" \
|
||||||
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
|
jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if ! jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1; then
|
||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
printf '%s\n' "$r"
|
printf '%s\n' "$r"
|
||||||
}
|
}
|
||||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||||
@@ -325,10 +368,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.22.0
|
CODEMAN_PREAMBLE=1.30.1
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -379,7 +422,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -440,9 +483,11 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it.
|
skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it.
|
||||||
- `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter.
|
- `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter.
|
||||||
A prompt without the `\r` is never submitted (§3), a reused `seq` is silently
|
A prompt without the `\r` is never submitted (§3), a reused `seq` is silently
|
||||||
swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint
|
swallowed as an already-applied duplicate, and a lost Enter strands the prompt on the
|
||||||
strands the prompt on the composer until a bare `\r` follows: all three are reasons
|
composer until a bare `\r` follows: Claude Code 2.1.277 and later ignore Enter for the
|
||||||
to let `sendwait` build the call rather than hand-rolling it.
|
first 30 to 50 seconds after the composer paints while still taking the text, so
|
||||||
|
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||||
|
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -72,6 +72,27 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
|
|||||||
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
||||||
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
||||||
}
|
}
|
||||||
|
# ---- the composer: is the prompt still sitting there, unsent? ----
|
||||||
|
# ⚠️ Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment the
|
||||||
|
# composer paints but IGNORES Enter for the first 30-50 seconds after it: the \r that
|
||||||
|
# Codeman sends 50 ms after the text and a lone nudge at 20 s both leave the prompt
|
||||||
|
# stranded, with `0 tokens`, while the wait burns its whole timeout. Measured through
|
||||||
|
# this very route: Enter at 28 s stranded, Enter at 51 s submitted. So sendwait READS
|
||||||
|
# the composer and keeps pressing Enter while the prompt is still there.
|
||||||
|
_composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "" once
|
||||||
|
# the prompt was taken, "?" when the pane shows no composer at all. The composer is
|
||||||
|
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
|
||||||
|
# up in the transcript, so only the last one says whether the text was taken.
|
||||||
|
local t
|
||||||
|
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
|
||||||
|
[ -n "$t" ] || { printf '?'; return 0; }
|
||||||
|
# Claude Code draws a NO-BREAK SPACE (U+00A0) after the glyph, which [:space:] does
|
||||||
|
# not cover, so it is stripped by its bytes, portably (BSD sed has no \xHH).
|
||||||
|
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
|
||||||
|
}
|
||||||
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
||||||
local sid="$1" k i=1
|
local sid="$1" k i=1
|
||||||
while [ "$i" -le 6 ]; do
|
while [ "$i" -le 6 ]; do
|
||||||
@@ -184,12 +205,15 @@ spawn_workers() {
|
|||||||
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
||||||
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
||||||
# deliberate duplicate, at the SAME number (§5.3).
|
# deliberate duplicate, at the SAME number (§5.3).
|
||||||
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
|
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
|
||||||
# typed prompt stranded on the composer while a long wait runs its whole timeout
|
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
|
||||||
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
# leaving the typed prompt stranded on the composer while a long wait runs its whole
|
||||||
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
|
||||||
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
# timeout the composer is READ (_composer_text), and while the prompt is still sitting
|
||||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
# there a bare \r goes out and a short re-wait follows, up to twelve times ten seconds
|
||||||
|
# apart. Each re-wait resends the ORIGINAL frame unchanged, which the server takes as a
|
||||||
|
# tagged duplicate: it re-waits without retyping (§5.3). An empty composer ends the
|
||||||
|
# loop, so a prompt that was taken is never nudged again. Trustworthy for a worker
|
||||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||||
@@ -197,7 +221,7 @@ spawn_workers() {
|
|||||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||||
# pane clearly finished means that profile, so switch that worker to markers.
|
# pane clearly finished means that profile, so switch that worker to markers.
|
||||||
sendwait() {
|
sendwait() {
|
||||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0
|
||||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||||
@@ -212,6 +236,17 @@ sendwait() {
|
|||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$body")
|
-H 'Content-Type: application/json' --data-binary "$body")
|
||||||
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||||
|
# The prompt's head with whitespace removed, matched literally (the "$head"
|
||||||
|
# quoting inside ${c#...} keeps a * or ? in the prompt from acting as a glob).
|
||||||
|
head=$(printf '%s' "$p" | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" | head -c 24)
|
||||||
|
while [ "$n" -lt 12 ]; do
|
||||||
|
c=$(_composer_text "$sid")
|
||||||
|
if [ "$c" = '?' ]; then
|
||||||
|
[ "$n" -eq 0 ] || break # unreadable pane: one Enter, then trust it
|
||||||
|
elif [ -z "$head" ] || [ "${c#"$head"}" = "$c" ]; then
|
||||||
|
break # composer empty (taken) or holding other text
|
||||||
|
fi
|
||||||
|
n=$((n+1))
|
||||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||||
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||||
@@ -219,10 +254,18 @@ sendwait() {
|
|||||||
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
||||||
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
||||||
# as an undelivered one and keeps a finished worker forever.
|
# as an undelivered one and keeps a finished worker forever.
|
||||||
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=10000' <<<"$body")" \
|
||||||
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
|
jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if ! jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1; then
|
||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
printf '%s\n' "$r"
|
printf '%s\n' "$r"
|
||||||
}
|
}
|
||||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||||
@@ -247,4 +290,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.22.0
|
CODEMAN_PREAMBLE=1.30.1
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
+61
-16
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
|
||||||
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
|
|||||||
mkdir -p "$(dirname "$PRE")"
|
mkdir -p "$(dirname "$PRE")"
|
||||||
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
|
||||||
# half-written file self-heals here instead of costing you a round trip to rm it.
|
# half-written file self-heals here instead of costing you a round trip to rm it.
|
||||||
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
|
||||||
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -150,6 +150,27 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
|
|||||||
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
||||||
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
||||||
}
|
}
|
||||||
|
# ---- the composer: is the prompt still sitting there, unsent? ----
|
||||||
|
# ⚠️ Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment the
|
||||||
|
# composer paints but IGNORES Enter for the first 30-50 seconds after it: the \r that
|
||||||
|
# Codeman sends 50 ms after the text and a lone nudge at 20 s both leave the prompt
|
||||||
|
# stranded, with `0 tokens`, while the wait burns its whole timeout. Measured through
|
||||||
|
# this very route: Enter at 28 s stranded, Enter at 51 s submitted. So sendwait READS
|
||||||
|
# the composer and keeps pressing Enter while the prompt is still there.
|
||||||
|
_composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "" once
|
||||||
|
# the prompt was taken, "?" when the pane shows no composer at all. The composer is
|
||||||
|
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
|
||||||
|
# up in the transcript, so only the last one says whether the text was taken.
|
||||||
|
local t
|
||||||
|
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
|
||||||
|
[ -n "$t" ] || { printf '?'; return 0; }
|
||||||
|
# Claude Code draws a NO-BREAK SPACE (U+00A0) after the glyph, which [:space:] does
|
||||||
|
# not cover, so it is stripped by its bytes, portably (BSD sed has no \xHH).
|
||||||
|
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
|
||||||
|
}
|
||||||
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
||||||
local sid="$1" k i=1
|
local sid="$1" k i=1
|
||||||
while [ "$i" -le 6 ]; do
|
while [ "$i" -le 6 ]; do
|
||||||
@@ -262,12 +283,15 @@ spawn_workers() {
|
|||||||
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
||||||
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
||||||
# deliberate duplicate, at the SAME number (§5.3).
|
# deliberate duplicate, at the SAME number (§5.3).
|
||||||
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
|
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
|
||||||
# typed prompt stranded on the composer while a long wait runs its whole timeout
|
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
|
||||||
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
# leaving the typed prompt stranded on the composer while a long wait runs its whole
|
||||||
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
|
||||||
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
# timeout the composer is READ (_composer_text), and while the prompt is still sitting
|
||||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
# there a bare \r goes out and a short re-wait follows, up to twelve times ten seconds
|
||||||
|
# apart. Each re-wait resends the ORIGINAL frame unchanged, which the server takes as a
|
||||||
|
# tagged duplicate: it re-waits without retyping (§5.3). An empty composer ends the
|
||||||
|
# loop, so a prompt that was taken is never nudged again. Trustworthy for a worker
|
||||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||||
@@ -275,7 +299,7 @@ spawn_workers() {
|
|||||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||||
# pane clearly finished means that profile, so switch that worker to markers.
|
# pane clearly finished means that profile, so switch that worker to markers.
|
||||||
sendwait() {
|
sendwait() {
|
||||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0
|
||||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||||
@@ -290,6 +314,17 @@ sendwait() {
|
|||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$body")
|
-H 'Content-Type: application/json' --data-binary "$body")
|
||||||
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||||
|
# The prompt's head with whitespace removed, matched literally (the "$head"
|
||||||
|
# quoting inside ${c#...} keeps a * or ? in the prompt from acting as a glob).
|
||||||
|
head=$(printf '%s' "$p" | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" | head -c 24)
|
||||||
|
while [ "$n" -lt 12 ]; do
|
||||||
|
c=$(_composer_text "$sid")
|
||||||
|
if [ "$c" = '?' ]; then
|
||||||
|
[ "$n" -eq 0 ] || break # unreadable pane: one Enter, then trust it
|
||||||
|
elif [ -z "$head" ] || [ "${c#"$head"}" = "$c" ]; then
|
||||||
|
break # composer empty (taken) or holding other text
|
||||||
|
fi
|
||||||
|
n=$((n+1))
|
||||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||||
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||||
@@ -297,10 +332,18 @@ sendwait() {
|
|||||||
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
||||||
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
||||||
# as an undelivered one and keeps a finished worker forever.
|
# as an undelivered one and keeps a finished worker forever.
|
||||||
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=10000' <<<"$body")" \
|
||||||
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
|
jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if ! jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1; then
|
||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
printf '%s\n' "$r"
|
printf '%s\n' "$r"
|
||||||
}
|
}
|
||||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||||
@@ -325,10 +368,10 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.22.0
|
CODEMAN_PREAMBLE=1.30.1
|
||||||
PREAMBLE
|
PREAMBLE
|
||||||
)
|
)
|
||||||
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Every later Bash call that touches the API starts with the same two loader lines from
|
Every later Bash call that touches the API starts with the same two loader lines from
|
||||||
@@ -379,7 +422,7 @@ and no per-call body to hand-build.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
|
||||||
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
|
||||||
# (a name may carry a mode: `beta:deepseek`, see below)
|
# (a name may carry a mode: `beta:deepseek`, see below)
|
||||||
T=('reply with one line: the absolute path of your working directory'
|
T=('reply with one line: the absolute path of your working directory'
|
||||||
@@ -440,9 +483,11 @@ Four things this block leans on, each one link away, no detour needed to run it:
|
|||||||
skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it.
|
skill: §5.1. Those workspaces do get hooks now, unless the operator disabled it.
|
||||||
- `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter.
|
- `sendwait` supplies the `\r`, picks a fresh `seq`, and self-heals a stranded Enter.
|
||||||
A prompt without the `\r` is never submitted (§3), a reused `seq` is silently
|
A prompt without the `\r` is never submitted (§3), a reused `seq` is silently
|
||||||
swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint
|
swallowed as an already-applied duplicate, and a lost Enter strands the prompt on the
|
||||||
strands the prompt on the composer until a bare `\r` follows: all three are reasons
|
composer until a bare `\r` follows: Claude Code 2.1.277 and later ignore Enter for the
|
||||||
to let `sendwait` build the call rather than hand-rolling it.
|
first 30 to 50 seconds after the composer paints while still taking the text, so
|
||||||
|
`sendwait` reads the composer and keeps pressing Enter until the prompt has left it.
|
||||||
|
All three are reasons to let `sendwait` build the call rather than hand-rolling it.
|
||||||
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
|
||||||
- Deleting the sessions does **not** remove the case directories. They are marked as
|
- Deleting the sessions does **not** remove the case directories. They are marked as
|
||||||
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ---- Codeman agent preamble 1.22.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
# ---- Codeman agent preamble 1.30.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
|
||||||
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
|
||||||
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
|
||||||
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
# Credentials, cheapest first. Your session has usually INHERITED the server's
|
||||||
@@ -72,6 +72,27 @@ _trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
|
|||||||
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
|
||||||
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
|
||||||
}
|
}
|
||||||
|
# ---- the composer: is the prompt still sitting there, unsent? ----
|
||||||
|
# ⚠️ Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment the
|
||||||
|
# composer paints but IGNORES Enter for the first 30-50 seconds after it: the \r that
|
||||||
|
# Codeman sends 50 ms after the text and a lone nudge at 20 s both leave the prompt
|
||||||
|
# stranded, with `0 tokens`, while the wait burns its whole timeout. Measured through
|
||||||
|
# this very route: Enter at 28 s stranded, Enter at 51 s submitted. So sendwait READS
|
||||||
|
# the composer and keeps pressing Enter while the prompt is still there.
|
||||||
|
_composer_text() { # <sid> -> the composer's text with ALL whitespace removed: "" once
|
||||||
|
# the prompt was taken, "?" when the pane shows no composer at all. The composer is
|
||||||
|
# the LAST `❯` line: Claude Code echoes a submitted prompt with the same glyph higher
|
||||||
|
# up in the transcript, so only the last one says whether the text was taken.
|
||||||
|
local t
|
||||||
|
t=$("${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
|
||||||
|
| jq -r '.data.terminalBuffer // empty' \
|
||||||
|
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
|
||||||
|
| tr -d '\r' | grep -a '^[[:space:]]*❯' | tail -1)
|
||||||
|
[ -n "$t" ] || { printf '?'; return 0; }
|
||||||
|
# Claude Code draws a NO-BREAK SPACE (U+00A0) after the glyph, which [:space:] does
|
||||||
|
# not cover, so it is stripped by its bytes, portably (BSD sed has no \xHH).
|
||||||
|
printf '%s' "$t" | sed 's/^[[:space:]]*❯//' | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g"
|
||||||
|
}
|
||||||
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
|
||||||
local sid="$1" k i=1
|
local sid="$1" k i=1
|
||||||
while [ "$i" -le 6 ]; do
|
while [ "$i" -le 6 ]; do
|
||||||
@@ -184,12 +205,15 @@ spawn_workers() {
|
|||||||
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
# worker a silent no-op that still "succeeds" and reports the previous turn's state.
|
||||||
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
# Pass seq explicitly for exactly one reason: resending a possibly-delivered frame as a
|
||||||
# deliberate duplicate, at the SAME number (§5.3).
|
# deliberate duplicate, at the SAME number (§5.3).
|
||||||
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
|
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
|
||||||
# typed prompt stranded on the composer while a long wait runs its whole timeout
|
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
|
||||||
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
|
# leaving the typed prompt stranded on the composer while a long wait runs its whole
|
||||||
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
|
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
|
||||||
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
|
# timeout the composer is READ (_composer_text), and while the prompt is still sitting
|
||||||
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
|
# there a bare \r goes out and a short re-wait follows, up to twelve times ten seconds
|
||||||
|
# apart. Each re-wait resends the ORIGINAL frame unchanged, which the server takes as a
|
||||||
|
# tagged duplicate: it re-waits without retyping (§5.3). An empty composer ends the
|
||||||
|
# loop, so a prompt that was taken is never nudged again. Trustworthy for a worker
|
||||||
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
|
||||||
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
# and for those only. Hook-less workspaces and the other modes resolve on flapping
|
||||||
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
|
||||||
@@ -197,7 +221,7 @@ spawn_workers() {
|
|||||||
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
|
||||||
# pane clearly finished means that profile, so switch that worker to markers.
|
# pane clearly finished means that profile, so switch that worker to markers.
|
||||||
sendwait() {
|
sendwait() {
|
||||||
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
|
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0
|
||||||
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
|
||||||
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
|
||||||
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
|
||||||
@@ -212,6 +236,17 @@ sendwait() {
|
|||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$body")
|
-H 'Content-Type: application/json' --data-binary "$body")
|
||||||
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
|
||||||
|
# The prompt's head with whitespace removed, matched literally (the "$head"
|
||||||
|
# quoting inside ${c#...} keeps a * or ? in the prompt from acting as a glob).
|
||||||
|
head=$(printf '%s' "$p" | tr -d '[:space:]' | sed "s/$(printf '\302\240')//g" | head -c 24)
|
||||||
|
while [ "$n" -lt 12 ]; do
|
||||||
|
c=$(_composer_text "$sid")
|
||||||
|
if [ "$c" = '?' ]; then
|
||||||
|
[ "$n" -eq 0 ] || break # unreadable pane: one Enter, then trust it
|
||||||
|
elif [ -z "$head" ] || [ "${c#"$head"}" = "$c" ]; then
|
||||||
|
break # composer empty (taken) or holding other text
|
||||||
|
fi
|
||||||
|
n=$((n+1))
|
||||||
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
|
||||||
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
|
||||||
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
|
||||||
@@ -219,10 +254,18 @@ sendwait() {
|
|||||||
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
# `delivered:false` for it -- truthfully, but about the wrong send. The first
|
||||||
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
|
||||||
# as an undelivered one and keeps a finished worker forever.
|
# as an undelivered one and keeps a finished worker forever.
|
||||||
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=10000' <<<"$body")" \
|
||||||
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
|
jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if ! jq -e '.data.wait.ended' <<<"$r" >/dev/null 2>&1; then
|
||||||
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
|
||||||
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
|
||||||
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
printf '%s\n' "$r"
|
printf '%s\n' "$r"
|
||||||
}
|
}
|
||||||
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
|
||||||
@@ -247,4 +290,4 @@ last_text() {
|
|||||||
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
|
||||||
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
# bare on purpose: the write condition above anchors on it with $, so an inline comment
|
||||||
# here would fail that match and rewrite this file on every single bootstrap.
|
# here would fail that match and rewrite this file on every single bootstrap.
|
||||||
CODEMAN_PREAMBLE=1.22.0
|
CODEMAN_PREAMBLE=1.30.1
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
|
||||||
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
[ "${CODEMAN_PREAMBLE:-}" = 1.30.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Verify that a programmatically sent prompt actually LEFT the composer,
|
||||||
|
* and press Enter again while it has not.
|
||||||
|
*
|
||||||
|
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
|
||||||
|
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
|
||||||
|
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
|
||||||
|
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
|
||||||
|
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
|
||||||
|
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
|
||||||
|
* the input route on 2026-09-19: an Enter at 28 s stranded, one at 51 s submitted.
|
||||||
|
*
|
||||||
|
* The rule: after a write that carried a carriage return, read the pane on a short
|
||||||
|
* schedule; while the LAST composer line (the CLI's own prompt glyph) still holds the
|
||||||
|
* head of what was sent, send Enter again. An empty composer ends it, and so does a
|
||||||
|
* composer holding anything else, because that text is the user's or the CLI's, never
|
||||||
|
* ours. A pane with no composer line at all (a shell, a CLI whose glyph is not
|
||||||
|
* declared, a direct-PTY session with no pane to read) does nothing: this runs for
|
||||||
|
* EVERY programmatic sender, so a blind Enter here could confirm a dialog nobody asked
|
||||||
|
* about. The composer is the last glyph line on purpose: Claude Code echoes a submitted
|
||||||
|
* prompt with the same glyph higher up in the transcript, so only the last one says
|
||||||
|
* whether the text was taken.
|
||||||
|
*
|
||||||
|
* Pure apart from the injected capture, send and log, so the schedule, the cap and
|
||||||
|
* every stop condition are unit-tested with fake timers (test/session-submit-verifier.test.ts).
|
||||||
|
*/
|
||||||
|
import { stripAnsi } from './utils/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When to look, counted from the write: 2 s catches the common case (taken) with one
|
||||||
|
* capture, and the tail reaches 60 s, past twice the longest window measured. Enter is
|
||||||
|
* re-sent at every check that still finds the prompt, so a 50 s window costs about
|
||||||
|
* seven Enters and one capture each; a taken prompt costs one capture.
|
||||||
|
*/
|
||||||
|
export const SUBMIT_VERIFY_DELAYS_MS: readonly number[] = [
|
||||||
|
2_000, 3_000, 5_000, 5_000, 5_000, 10_000, 10_000, 10_000, 10_000,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** How many leading characters of the prompt have to match, whitespace removed. */
|
||||||
|
const PROMPT_HEAD_CHARS = 24;
|
||||||
|
|
||||||
|
const compact = (s: string): string => s.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `prompt` is still sitting unsubmitted in the composer of `screen`.
|
||||||
|
*
|
||||||
|
* - `true`: the last `glyph` line holds the prompt's head.
|
||||||
|
* - `false`: the composer is empty (the prompt was taken) or holds other text.
|
||||||
|
* - `undefined`: no composer line at all; nothing can be said, so nothing is sent.
|
||||||
|
*
|
||||||
|
* Whitespace is removed on both sides before comparing, because the composer wraps a
|
||||||
|
* long prompt onto indented continuation lines and Claude Code draws a no-break space
|
||||||
|
* after the glyph; `\s` covers that one in JavaScript.
|
||||||
|
*/
|
||||||
|
export function promptStillInComposer(screen: string, prompt: string, glyph: string): boolean | undefined {
|
||||||
|
if (!glyph) return undefined;
|
||||||
|
const composerLines = stripAnsi(screen)
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.startsWith(glyph));
|
||||||
|
if (composerLines.length === 0) return undefined;
|
||||||
|
const composer = compact(composerLines[composerLines.length - 1].slice(glyph.length));
|
||||||
|
if (!composer) return false;
|
||||||
|
const head = compact(prompt).slice(0, PROMPT_HEAD_CHARS);
|
||||||
|
return head.length > 0 && composer.startsWith(head);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitVerifierDeps {
|
||||||
|
/** The rendered pane, or null when there is none to read. */
|
||||||
|
capture: () => string | null | undefined;
|
||||||
|
/** Press Enter once. Failures are swallowed; the next check decides again. */
|
||||||
|
sendEnter: () => Promise<unknown> | unknown;
|
||||||
|
/** The CLI's composer glyph, resolved at check time (the registry can change). */
|
||||||
|
glyph: () => string;
|
||||||
|
log?: (message: string) => void;
|
||||||
|
/** Test seam; production uses SUBMIT_VERIFY_DELAYS_MS. */
|
||||||
|
delaysMs?: readonly number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One per session. `arm(text)` starts the schedule for the prompt just sent and
|
||||||
|
* cancels any earlier one: a newer write owns the composer now, and re-sending Enter
|
||||||
|
* for an older prompt could submit the newer one early. `cancel()` is for teardown.
|
||||||
|
*/
|
||||||
|
export class SubmitVerifier {
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private generation = 0;
|
||||||
|
|
||||||
|
constructor(private readonly deps: SubmitVerifierDeps) {}
|
||||||
|
|
||||||
|
arm(text: string): void {
|
||||||
|
this.cancel();
|
||||||
|
const gen = this.generation;
|
||||||
|
const delays = this.deps.delaysMs ?? SUBMIT_VERIFY_DELAYS_MS;
|
||||||
|
let step = 0;
|
||||||
|
let elapsed = 0;
|
||||||
|
let resent = 0;
|
||||||
|
|
||||||
|
const schedule = (): void => {
|
||||||
|
if (step >= delays.length) return;
|
||||||
|
const delay = delays[step++];
|
||||||
|
elapsed += delay;
|
||||||
|
this.timer = setTimeout(() => void check(), delay);
|
||||||
|
this.timer.unref?.();
|
||||||
|
};
|
||||||
|
const check = async (): Promise<void> => {
|
||||||
|
this.timer = null;
|
||||||
|
if (gen !== this.generation) return;
|
||||||
|
const screen = this.deps.capture();
|
||||||
|
if (promptStillInComposer(screen ?? '', text, this.deps.glyph()) !== true) return;
|
||||||
|
resent++;
|
||||||
|
this.deps.log?.(
|
||||||
|
`prompt still in the composer after ${Math.round(elapsed / 1000)}s, re-sending Enter (${resent}/${delays.length})`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.deps.sendEnter();
|
||||||
|
} catch {
|
||||||
|
// The next check re-reads the screen and decides again.
|
||||||
|
}
|
||||||
|
if (gen !== this.generation) return;
|
||||||
|
schedule();
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.generation++;
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-1
@@ -110,6 +110,7 @@ import {
|
|||||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
import { getCli } from './config/cli-registry/registry.js';
|
import { getCli } from './config/cli-registry/registry.js';
|
||||||
|
import { SubmitVerifier } from './session-submit-verifier.js';
|
||||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||||
import {
|
import {
|
||||||
@@ -502,6 +503,8 @@ export class Session extends EventEmitter {
|
|||||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||||
private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below)
|
private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below)
|
||||||
|
/** Re-sends Enter while a programmatic prompt still sits in the composer (session-submit-verifier.ts). */
|
||||||
|
private _submitVerifier: SubmitVerifier | null = null;
|
||||||
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
|
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
|
||||||
private _taskTracker: TaskTracker;
|
private _taskTracker: TaskTracker;
|
||||||
|
|
||||||
@@ -3190,6 +3193,9 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _clearAllTimers(): void {
|
private _clearAllTimers(): void {
|
||||||
|
// Stop re-sending Enter for a prompt this session will never take now
|
||||||
|
this._submitVerifier?.cancel();
|
||||||
|
this._submitVerifier = null;
|
||||||
// Clear the workspace-trust follow-up read
|
// Clear the workspace-trust follow-up read
|
||||||
if (this._trustDialogTimer) {
|
if (this._trustDialogTimer) {
|
||||||
clearTimeout(this._trustDialogTimer);
|
clearTimeout(this._trustDialogTimer);
|
||||||
@@ -3721,7 +3727,10 @@ export class Session extends EventEmitter {
|
|||||||
const submittedPrompt = this._trackSubmit(data, options);
|
const submittedPrompt = this._trackSubmit(data, options);
|
||||||
if (this._mux && this._muxSession) {
|
if (this._mux && this._muxSession) {
|
||||||
const sent = await this._mux.sendInput(this.id, data);
|
const sent = await this._mux.sendInput(this.id, data);
|
||||||
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
if (sent) {
|
||||||
|
this._emitSubmittedPrompt(submittedPrompt);
|
||||||
|
this._verifySubmitted(data);
|
||||||
|
}
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
// Fallback to PTY write
|
// Fallback to PTY write
|
||||||
@@ -3733,6 +3742,28 @@ export class Session extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
||||||
|
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
||||||
|
* so the pair `sendInput` just sent can leave the text stranded. Only a mux session
|
||||||
|
* can read its pane, only text can be stranded, and the glyph is the CLI's own.
|
||||||
|
*/
|
||||||
|
private _verifySubmitted(data: string): void {
|
||||||
|
if (!data.includes('\r') || !this._mux?.capturePaneText || !this._muxSession) return;
|
||||||
|
const text = data.replace(/[\r\n]/g, '').trimEnd();
|
||||||
|
if (!text) return;
|
||||||
|
this._submitVerifier ??= new SubmitVerifier({
|
||||||
|
capture: () =>
|
||||||
|
this._isStopped || !this._mux || !this._muxSession
|
||||||
|
? null
|
||||||
|
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||||
|
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||||
|
glyph: () => getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯',
|
||||||
|
log: (m) => console.log(`[Session ${this.id.slice(0, 8)}] ${m}`),
|
||||||
|
});
|
||||||
|
this._submitVerifier.arm(text);
|
||||||
|
}
|
||||||
|
|
||||||
/** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */
|
/** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */
|
||||||
private _ptyCols = 120;
|
private _ptyCols = 120;
|
||||||
private _ptyRows = 40;
|
private _ptyRows = 40;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview A prompt sent through the input route must actually leave the composer.
|
||||||
|
*
|
||||||
|
* Claude Code 2.1.277 ignores Enter for the first 30 to 50 seconds after the composer
|
||||||
|
* paints while still taking typed text, so text+Enter 50 ms apart left every
|
||||||
|
* programmatic prompt sitting unsent (measured 2026-09-19). These pin the recovery:
|
||||||
|
* the verifier reads the last glyph line, re-sends Enter only while the prompt is
|
||||||
|
* verifiably still there, stops the moment it is gone, never acts on a pane with no
|
||||||
|
* composer, is capped, and is cancelled by a newer write or teardown.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { SubmitVerifier, promptStillInComposer, SUBMIT_VERIFY_DELAYS_MS } from '../src/session-submit-verifier.js';
|
||||||
|
|
||||||
|
const PROMPT =
|
||||||
|
'Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes. Do not ask questions.';
|
||||||
|
|
||||||
|
/** Measured 2026-09-19: typed, wrapped, a no-break space after the glyph, never sent. */
|
||||||
|
const STRANDED = [
|
||||||
|
' ▐▛███▛█ Claude Code v2.1.278',
|
||||||
|
'──────────────────────────────────────── prbot-439 ─',
|
||||||
|
'❯ Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes. Do not ask',
|
||||||
|
' questions.',
|
||||||
|
'────────────────────────────────────────────────────',
|
||||||
|
' Opus 5 (1M context) in:0 out:0',
|
||||||
|
' ⏵⏵ bypass permissions on (shift+tab to cycle)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
/** The same pane once taken: echoed in the transcript, composer empty. */
|
||||||
|
const TAKEN = [
|
||||||
|
'❯ Read /home/arkon/.codeman/pr-bot/jobs/pr-439/brief.md and carry out the review it describes.',
|
||||||
|
'● Reading the brief.',
|
||||||
|
'✻ Actioning… (48s · ↓ 6.6k tokens)',
|
||||||
|
'──────────────────────────────────────── prbot-439 ─',
|
||||||
|
'❯',
|
||||||
|
'────────────────────────────────────────────────────',
|
||||||
|
' Opus 5 (1M context) in:192,963 out:371 ctx:19%',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
describe('promptStillInComposer', () => {
|
||||||
|
it('sees the prompt sitting in the composer, no-break space and wrapping included', () => {
|
||||||
|
expect(promptStillInComposer(STRANDED, PROMPT, '❯')).toBe(true);
|
||||||
|
});
|
||||||
|
it('is not fooled by the transcript echo once the composer is empty', () => {
|
||||||
|
expect(promptStillInComposer(TAKEN, PROMPT, '❯')).toBe(false);
|
||||||
|
});
|
||||||
|
it('treats other text in the composer as not ours', () => {
|
||||||
|
expect(promptStillInComposer(STRANDED, 'Summarise the changelog', '❯')).toBe(false);
|
||||||
|
});
|
||||||
|
it('answers undefined for a pane with no composer line, or no glyph', () => {
|
||||||
|
expect(promptStillInComposer('$ ls\nfoo bar\n$ ', PROMPT, '❯')).toBeUndefined();
|
||||||
|
expect(promptStillInComposer(STRANDED, PROMPT, '')).toBeUndefined();
|
||||||
|
});
|
||||||
|
it('honours the CLI glyph (Codex draws ›)', () => {
|
||||||
|
expect(promptStillInComposer('› Reply with PONG\n', 'Reply with PONG', '›')).toBe(true);
|
||||||
|
expect(promptStillInComposer('›\n', 'Reply with PONG', '›')).toBe(false);
|
||||||
|
});
|
||||||
|
it('reads through ANSI colour codes', () => {
|
||||||
|
expect(promptStillInComposer('\x1b[1m❯\x1b[0m \x1b[36mReply with PONG\x1b[0m', 'Reply with PONG', '❯')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SubmitVerifier', () => {
|
||||||
|
let screen: string;
|
||||||
|
let sends: number;
|
||||||
|
let logs: string[];
|
||||||
|
const make = (delaysMs?: readonly number[]) =>
|
||||||
|
new SubmitVerifier({
|
||||||
|
capture: () => screen,
|
||||||
|
sendEnter: () => {
|
||||||
|
sends++;
|
||||||
|
},
|
||||||
|
glyph: () => '❯',
|
||||||
|
log: (m) => logs.push(m),
|
||||||
|
delaysMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
screen = STRANDED;
|
||||||
|
sends = 0;
|
||||||
|
logs = [];
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-sends Enter while the prompt is still there and stops once it is taken', async () => {
|
||||||
|
const v = make([1_000, 1_000, 1_000, 1_000]);
|
||||||
|
v.arm(PROMPT);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(sends).toBe(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(sends).toBe(2);
|
||||||
|
screen = TAKEN; // Claude Code finally honoured one
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(sends).toBe(2);
|
||||||
|
expect(logs[0]).toContain('still in the composer after 1s');
|
||||||
|
expect(logs[1]).toContain('(2/4)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costs one capture and no Enter when the prompt was taken on the first try', async () => {
|
||||||
|
let captures = 0;
|
||||||
|
const v = new SubmitVerifier({
|
||||||
|
capture: () => {
|
||||||
|
captures++;
|
||||||
|
return TAKEN;
|
||||||
|
},
|
||||||
|
sendEnter: () => {
|
||||||
|
sends++;
|
||||||
|
},
|
||||||
|
glyph: () => '❯',
|
||||||
|
});
|
||||||
|
v.arm(PROMPT);
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
expect(captures).toBe(1);
|
||||||
|
expect(sends).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never presses Enter into a pane with no composer line', async () => {
|
||||||
|
screen = '$ npm test\n... running ...\n';
|
||||||
|
const v = make([1_000, 1_000]);
|
||||||
|
v.arm(PROMPT);
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(sends).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is capped at the schedule length when the prompt never leaves', async () => {
|
||||||
|
const v = make();
|
||||||
|
v.arm(PROMPT);
|
||||||
|
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||||
|
expect(sends).toBe(SUBMIT_VERIFY_DELAYS_MS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('production schedule reaches past the measured window', () => {
|
||||||
|
const total = SUBMIT_VERIFY_DELAYS_MS.reduce((a, b) => a + b, 0);
|
||||||
|
expect(SUBMIT_VERIFY_DELAYS_MS[0]).toBeLessThanOrEqual(2_000);
|
||||||
|
expect(total).toBeGreaterThanOrEqual(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a newer write replaces the schedule, so an old prompt never submits a new one', async () => {
|
||||||
|
const v = make([1_000, 1_000, 1_000]);
|
||||||
|
v.arm(PROMPT);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(sends).toBe(1);
|
||||||
|
screen = '❯ Something the user typed next';
|
||||||
|
v.arm('Something the user typed next');
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
expect(sends).toBe(2); // for the NEW prompt, which is what the composer holds
|
||||||
|
screen = '❯';
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(sends).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancel() stops everything', async () => {
|
||||||
|
const v = make([1_000, 1_000]);
|
||||||
|
v.arm(PROMPT);
|
||||||
|
v.cancel();
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(sends).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps checking when sendEnter throws', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const v = new SubmitVerifier({
|
||||||
|
capture: () => screen,
|
||||||
|
sendEnter: () => {
|
||||||
|
calls++;
|
||||||
|
throw new Error('tmux hiccup');
|
||||||
|
},
|
||||||
|
glyph: () => '❯',
|
||||||
|
delaysMs: [1_000, 1_000],
|
||||||
|
});
|
||||||
|
v.arm(PROMPT);
|
||||||
|
await vi.advanceTimersByTimeAsync(3_000);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user