Compare commits

..
Author SHA1 Message Date
Codeman maintainer a0298cf2b1 fix(skill): keep the re-wait open while sendwait works the composer
The first shape of the Enter loop read the composer BETWEEN two short waits,
and tested `wait.ended` (the session exiting) where it meant `timedOut`. A
`stop` that fired while no wait was open was lost, since signals have no
history, and a re-wait that had already resolved on `stop` fell through into
another wait that could never see the edge again: measured twice, the answer
was on screen and sendwait ran its whole 580 s slice anyway.

The long re-wait (a tagged duplicate of the original frame) is now registered
first and kept open in the background for the rest of the call; the loop reads
the composer and re-sends Enter beside it, stops when the prompt has left or
the wait's response has landed, then returns that response. Measured: the
stranded prompt got one extra Enter and sendwait returned on `stop` at 36 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:49:02 +02:00
Codeman maintainer 19ffe9b7a8 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>
2026-09-19 11:38:59 +02:00
Codeman maintainer 3cdb4bf42e docs(terminal): the merge-time notes promised on #436
The four edits the review said would be folded in at merge, none of them
code: the changeset becomes one user-facing paragraph, since it is what
CHANGELOG.md and the release notes print; the `_bufferLoadFinishOpts` comment
now names the second contributor to the duplicate window (`captureActivePaneBuffer`
is `execSync`, so anything painted into the pane before the server read it is
in the capture and is broadcast after the reply) and says why a `history`
payload keeps the pre-existing discard when its exposure is the same; the
`_finishBufferLoad` doc block moves from above `_beginBufferLoad` onto the
function it documents; and the test file's header describes both rules the
file now pins instead of only COD-144.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 21:38:40 +02:00
Ark0N 492f8d8ddf Merge pull request #436 from irisitymichaelgrundberg/fix/replay-output-that-arrived-after-the-capture
fix(terminal): keep the output a pane capture could not contain
2026-09-18 21:34:42 +02:00
Michael GrundbergandClaude Opus 5 3730bc7df5 docs(terminal): correct what selectSession does with the viewport
The JSDoc on `_syncStickyScrollBaseline` said `selectSession` deliberately ends
at the bottom, so the baseline the replay samples is already true there. It
does not. `selectSession` calls `scrollToBottom()` after the write and then
ends at `scrollToLastNonEmptyLine()` (app.js:6512), which targets
`lastNonEmptyLine - rows + 2` and therefore parks ABOVE `baseY` whenever the
replayed frame keeps trailing blank rows — which a full capture does on
purpose, since no transform that can delete a line may run over one.

Its baseline really is a stale true. What covers it is the sticky snap itself:
since de864e7d that snap fires only when the flush found the viewport already
at the bottom (`preserveViewportY === null`), which a parked selectSession
viewport is not. That commit landed on master after this branch was cut, so
the guard arrives with the merge rather than being present here.

`_onSessionClearTerminal` is unchanged in the comment and was correct: it
resets and rewrites with no scroll afterwards, so it does end at the bottom.

Comment only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:56:15 +02:00
Michael GrundbergandClaude Opus 5 cfd771d1d8 test(terminal): pin all four buffer-load paths to the shared flush helper
The first version of this fix decided the flush policy in `selectSession`
alone, and a later pass found it still covering one path of four. Nothing in
the CI gate stops a fifth path, or an inlined `{ flushQueued: true }`, from
splitting that policy up again — the browser suite that would notice is
excluded from `npm test`.

A static scan over `selectSession`, `_onSessionNeedsRefresh`,
`_onSessionClearTerminal` and `_maybeRefetchFullHistory` asserts each one asks
`_bufferLoadFinishOpts`, reusing the `methodBody` slice the sticky-scroll guard
already needed. Verified by inlining the policy back into
`_onSessionClearTerminal`, which fails it by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:44:04 +02:00
Michael GrundbergandClaude Opus 5 75a028e825 fix(terminal): re-take the sticky-scroll baseline after a replay
A capture load now replays its queued tail, and that replay runs through
`batchTerminalWrite`, which samples `_wasAtBottomBeforeWrite` before it queues.
It runs inside `chunkedTerminalWrite`, before that promise resolves, with the
terminal freshly reset and rewritten — so the sample is always true. The caller
then restored the reader's position and the next `flushPendingWrites` scrolled
straight back to the bottom off the latched flag, undoing it. The only thing in
the way was `_hasRecentUserScrollUp()`, a 1500ms window a server-triggered
refresh is usually past.

`_syncStickyScrollBaseline()` re-takes the flag from wherever the viewport now
sits, and the two paths that restore a position call it right after doing so:
`_onSessionNeedsRefresh` and `_maybeRefetchFullHistory`. Those are the paths
#259 and #205 exist for, and they are also where a non-empty queue is most
likely, since a needsRefresh fires when output is flooding. Re-taking rather
than suppressing the sampling: suppressing leaves whatever stale value the flag
held from before the load, which on the full-history re-pull has no reason to
be false. `selectSession` and `_onSessionClearTerminal` deliberately end at the
bottom, so the sampled true is already the truth there and they do not call it.

`_bufferLoadFinishOpts` gains the coverage the CI gate can see: both mux
sources flush, `history` does not, and a payload naming no source does not.
Its only coverage was the browser suite, which CI does not run.

The JSDoc and the changeset now record the one duplicate window this cutoff
cannot close. The server appends output to the byte buffer in the same tick it
emits, but broadcasts on a batch timer — 8ms over WebSocket, 16 to 50ms over
SSE — so a batch pending when `capture-pane` ran leaves the server after the
reply and is replayed although the capture holds it. It is one batch interval
wide against a recovery window spanning the whole chunked write, and closing it
means flushing that batch server side before the capture.

The second browser test asserts its session was created, so a failed create
fails it instead of passing with zero hits.

docs/architecture-invariants.md no longer claims the replay leaves the
queued-event discard window alone. That clause now describes what decides how a
load ends, the baseline rule, the batch window, and the three covering tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:43:04 +02:00
Michael GrundbergandClaude Opus 5 c9515b1d4c fix(terminal): keep the output a pane capture could not contain
Live terminal events are queued while a buffer load runs, and the load discards
that queue when it ends. That is right when the loaded buffer is the server's
accumulated byte history. The route appends to that history right up to the
moment it serializes the response, so a queued event already appears in it and
replaying it would duplicate output, most visibly Ink's cursor-up redraws.

A tmux pane capture is a photograph, current only as of the instant
`capture-pane` ran. Output printed afterwards was queued and then dropped, and
nothing scheduled a re-fetch to recover it: `_onSessionNeedsRefresh` is wired
only to the 128KB overflow path. The CLI's next partial redraw then landed on a
frame the terminal never received.

How much went missing depended on which capture the route served. A `?full=1`
load returns the capture alone, with no history in front of it, so it lost
everything from the capture to the end of the chunked write. A `?tail=` load
returns history, a clear, and then the capture, and the route reads that history
after the capture, so it lost everything from the response to the end of that
write. The chunked write dominates either way. An agent CLI hides the loss on
its next full redraw; a shell session does not, because its output is linear and
nothing repaints it.

Queue entries now carry their arrival time, and `_finishBufferLoad` takes a
`since` cutoff, so a capture load replays exactly the tail that arrived after
the response headers. The earlier events stay dropped, because a payload that
carries history does hold those.

All four paths that fetch a terminal buffer and write it now decide this the
same way, through one `_bufferLoadFinishOpts` helper, so they cannot drift
apart: `selectSession`, `_onSessionNeedsRefresh`, `_onSessionClearTerminal` and
`_maybeRefetchFullHistory`. The second of those is the one that stings. It
exists to restore output the client already dropped once under backpressure, and
it was dropping more output while performing that recovery. The cache-hit write
inside `selectSession` stays on discard deliberately: it runs before the fetch,
so its queue holds only events the capture that follows already contains.

Two further things had to change for that tail to still exist when the load
ends, and a browser test is what found both. `chunkedTerminalWrite` is what ends
the load for every non-empty buffer, so the flush policy travels to its own
finish calls; the call in `selectSession` runs only when the write was skipped.
`_beginBufferLoad` no longer empties the queue when one load re-enters it, which
it does on every write, because that reset discarded the whole fetch window
before anything could replay it.

The response already distinguishes the sources. `source` reads `mux-visible` or
`mux-full-history` for a capture and `history` for the byte stream.

Follows #395, #396 and #397, which fixed the ways the replayed frame itself
could disagree with the terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 18:14:18 +02:00
19 changed files with 1288 additions and 143 deletions
@@ -0,0 +1,5 @@
---
"aicodeman": patch
---
fix(terminal): keep the output a pane capture could not contain. Opening a session, a backpressure refresh, a clear-terminal reload and a full-history re-pull all load the screen from a tmux pane capture, and anything the CLI printed between that capture and the end of the load used to be dropped, so its next partial redraw landed on a frame the terminal had never seen: missing or garbled output right after a tab switch or a refresh, plainest in a shell session. Each load now replays exactly the output that arrived after the capture, through one shared rule for all four paths, and a refresh that restores your scroll position no longer snaps back to the bottom afterwards.
+5
View File
@@ -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.
+1 -1
View File
@@ -128,7 +128,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
## 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
- **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)
+1
View File
@@ -27,6 +27,7 @@ export const BROWSER_TEST_GLOBS = [
'test/webgl-fallback.test.ts',
'test/terminal-copy-shortcut.test.ts',
'test/terminal-keycode229-recovery.browser.test.ts',
'test/capture-load-window.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
];
File diff suppressed because one or more lines are too long
+75 -26
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${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
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# 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.
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$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) ----
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- 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}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# 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 \
| 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
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
@@ -262,12 +283,16 @@ spawn_workers() {
# 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
# deliberate duplicate, at the SAME number (§5.3).
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
# typed prompt stranded on the composer while a long wait runs its whole timeout
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
# leaving the typed prompt stranded on the composer while a long wait runs its whole
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
# timeout the ORIGINAL frame is resent unchanged as a long re-wait (a tagged duplicate:
# the server re-waits without retyping, §5.3) and kept open in the background, while
# the composer is READ (_composer_text) and, as long as the prompt is still sitting
# there, a bare \r goes out about every ten seconds, up to twelve times. An empty
# composer ends the loop, so a prompt that was taken is never nudged again, and the
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# 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
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
@@ -275,7 +300,7 @@ spawn_workers() {
# 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.
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 tmp bg i
# `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
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -290,16 +315,38 @@ sendwait() {
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$body")
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
"${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)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
# The resend is a tagged DUPLICATE, so the server skips the write and reports
# `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
# 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=580000' <<<"$body")" \
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
# ⚠️ The long re-wait is registered FIRST and stays open for the rest of this call,
# in the background, while the Enter loop below works the composer. Signals have
# no history: a `stop` that fires while no wait is open (during a composer read
# between two short waits, measured) is lost, and the next wait then runs its
# whole timeout on a turn that already ended. The resend is a tagged DUPLICATE,
# so the server skips the write and re-waits without retyping (§5.3).
tmp=$(mktemp "${TMPDIR:-/tmp}/codeman-wait.XXXXXX") || return 1
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" > "$tmp" &
bg=$!
# 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 ] && [ ! -s "$tmp" ]; do # a non-empty file means the wait ended
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' \
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
i=0; while [ "$i" -lt 10 ] && [ ! -s "$tmp" ]; do sleep 1; i=$((i+1)); done
done
wait "$bg"
# The duplicate reports `delivered:false` -- truthfully, but about the wrong send.
# The first one delivered, so carry that forward, or §1's cleanup reads a completed
# turn as an undelivered one and keeps a finished worker forever.
r=$(jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end' < "$tmp")
rm -f "$tmp"
fi
printf '%s\n' "$r"
}
@@ -325,10 +372,10 @@ last_text() {
# 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
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.22.0
CODEMAN_PREAMBLE=1.30.1
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
@@ -379,7 +426,7 @@ and no per-call body to hand-build.
```bash
. "${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
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -440,9 +487,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.
- `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
swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint
strands the prompt on the composer until a bare `\r` follows: all three are reasons
to let `sendwait` build the call rather than hand-rolling it.
swallowed as an already-applied duplicate, and a lost Enter strands the prompt on the
composer until a bare `\r` follows: Claude Code 2.1.277 and later ignore Enter for the
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.
- 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.
+66 -19
View File
@@ -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}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# 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 \
| 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
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
@@ -184,12 +205,16 @@ spawn_workers() {
# 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
# deliberate duplicate, at the SAME number (§5.3).
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
# typed prompt stranded on the composer while a long wait runs its whole timeout
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
# leaving the typed prompt stranded on the composer while a long wait runs its whole
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
# timeout the ORIGINAL frame is resent unchanged as a long re-wait (a tagged duplicate:
# the server re-waits without retyping, §5.3) and kept open in the background, while
# the composer is READ (_composer_text) and, as long as the prompt is still sitting
# there, a bare \r goes out about every ten seconds, up to twelve times. An empty
# composer ends the loop, so a prompt that was taken is never nudged again, and the
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# 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
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
@@ -197,7 +222,7 @@ spawn_workers() {
# 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.
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 tmp bg i
# `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
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -212,16 +237,38 @@ sendwait() {
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$body")
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
"${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)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
# The resend is a tagged DUPLICATE, so the server skips the write and reports
# `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
# 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=580000' <<<"$body")" \
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
# ⚠️ The long re-wait is registered FIRST and stays open for the rest of this call,
# in the background, while the Enter loop below works the composer. Signals have
# no history: a `stop` that fires while no wait is open (during a composer read
# between two short waits, measured) is lost, and the next wait then runs its
# whole timeout on a turn that already ended. The resend is a tagged DUPLICATE,
# so the server skips the write and re-waits without retyping (§5.3).
tmp=$(mktemp "${TMPDIR:-/tmp}/codeman-wait.XXXXXX") || return 1
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" > "$tmp" &
bg=$!
# 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 ] && [ ! -s "$tmp" ]; do # a non-empty file means the wait ended
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' \
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
i=0; while [ "$i" -lt 10 ] && [ ! -s "$tmp" ]; do sleep 1; i=$((i+1)); done
done
wait "$bg"
# The duplicate reports `delivered:false` -- truthfully, but about the wrong send.
# The first one delivered, so carry that forward, or §1's cleanup reads a completed
# turn as an undelivered one and keeps a finished worker forever.
r=$(jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end' < "$tmp")
rm -f "$tmp"
fi
printf '%s\n' "$r"
}
@@ -247,4 +294,4 @@ last_text() {
# 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
# 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
. "${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
+75 -26
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${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
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# 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.
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$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) ----
grep -qs '^CODEMAN_PREAMBLE=1.30.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- 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}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# 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 \
| 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
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
@@ -262,12 +283,16 @@ spawn_workers() {
# 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
# deliberate duplicate, at the SAME number (§5.3).
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
# typed prompt stranded on the composer while a long wait runs its whole timeout
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
# leaving the typed prompt stranded on the composer while a long wait runs its whole
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
# timeout the ORIGINAL frame is resent unchanged as a long re-wait (a tagged duplicate:
# the server re-waits without retyping, §5.3) and kept open in the background, while
# the composer is READ (_composer_text) and, as long as the prompt is still sitting
# there, a bare \r goes out about every ten seconds, up to twelve times. An empty
# composer ends the loop, so a prompt that was taken is never nudged again, and the
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# 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
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
@@ -275,7 +300,7 @@ spawn_workers() {
# 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.
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 tmp bg i
# `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
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -290,16 +315,38 @@ sendwait() {
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$body")
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
"${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)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
# The resend is a tagged DUPLICATE, so the server skips the write and reports
# `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
# 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=580000' <<<"$body")" \
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
# ⚠️ The long re-wait is registered FIRST and stays open for the rest of this call,
# in the background, while the Enter loop below works the composer. Signals have
# no history: a `stop` that fires while no wait is open (during a composer read
# between two short waits, measured) is lost, and the next wait then runs its
# whole timeout on a turn that already ended. The resend is a tagged DUPLICATE,
# so the server skips the write and re-waits without retyping (§5.3).
tmp=$(mktemp "${TMPDIR:-/tmp}/codeman-wait.XXXXXX") || return 1
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" > "$tmp" &
bg=$!
# 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 ] && [ ! -s "$tmp" ]; do # a non-empty file means the wait ended
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' \
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
i=0; while [ "$i" -lt 10 ] && [ ! -s "$tmp" ]; do sleep 1; i=$((i+1)); done
done
wait "$bg"
# The duplicate reports `delivered:false` -- truthfully, but about the wrong send.
# The first one delivered, so carry that forward, or §1's cleanup reads a completed
# turn as an undelivered one and keeps a finished worker forever.
r=$(jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end' < "$tmp")
rm -f "$tmp"
fi
printf '%s\n' "$r"
}
@@ -325,10 +372,10 @@ last_text() {
# 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
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.22.0
CODEMAN_PREAMBLE=1.30.1
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
@@ -379,7 +426,7 @@ and no per-call body to hand-build.
```bash
. "${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
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -440,9 +487,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.
- `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
swallowed as an already-applied duplicate, and an Enter eaten by an Ink repaint
strands the prompt on the composer until a bare `\r` follows: all three are reasons
to let `sendwait` build the call rather than hand-rolling it.
swallowed as an already-applied duplicate, and a lost Enter strands the prompt on the
composer until a bare `\r` follows: Claude Code 2.1.277 and later ignore Enter for the
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.
- 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.
+66 -19
View File
@@ -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}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# 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 \
| 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
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
@@ -184,12 +205,16 @@ spawn_workers() {
# 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
# deliberate duplicate, at the SAME number (§5.3).
# Delivery is SELF-HEALING: an Ink repaint occasionally eats the Enter, leaving the
# typed prompt stranded on the composer while a long wait runs its whole timeout
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
# Delivery is SELF-HEALING: the Enter can be lost (an Ink repaint eats it, and Claude
# Code 2.1.277+ ignores it outright for the first 30-50 s after the composer paints),
# leaving the typed prompt stranded on the composer while a long wait runs its whole
# timeout (observed live, twelve reviews in a row). So the first wait is short; on its
# timeout the ORIGINAL frame is resent unchanged as a long re-wait (a tagged duplicate:
# the server re-waits without retyping, §5.3) and kept open in the background, while
# the composer is READ (_composer_text) and, as long as the prompt is still sitting
# there, a bare \r goes out about every ten seconds, up to twelve times. An empty
# composer ends the loop, so a prompt that was taken is never nudged again, and the
# wait that was open the whole time is what reports the turn's end. Trustworthy for a worker
# 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
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
@@ -197,7 +222,7 @@ spawn_workers() {
# 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.
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 tmp bg i
# `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
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
@@ -212,16 +237,38 @@ sendwait() {
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$body")
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
"${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)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
# The resend is a tagged DUPLICATE, so the server skips the write and reports
# `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
# 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=580000' <<<"$body")" \
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
# ⚠️ The long re-wait is registered FIRST and stays open for the rest of this call,
# in the background, while the Enter loop below works the composer. Signals have
# no history: a `stop` that fires while no wait is open (during a composer read
# between two short waits, measured) is lost, and the next wait then runs its
# whole timeout on a turn that already ended. The resend is a tagged DUPLICATE,
# so the server skips the write and re-waits without retyping (§5.3).
tmp=$(mktemp "${TMPDIR:-/tmp}/codeman-wait.XXXXXX") || return 1
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" > "$tmp" &
bg=$!
# 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 ] && [ ! -s "$tmp" ]; do # a non-empty file means the wait ended
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' \
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
i=0; while [ "$i" -lt 10 ] && [ ! -s "$tmp" ]; do sleep 1; i=$((i+1)); done
done
wait "$bg"
# The duplicate reports `delivered:false` -- truthfully, but about the wrong send.
# The first one delivered, so carry that forward, or §1's cleanup reads a completed
# turn as an undelivered one and keeps a finished worker forever.
r=$(jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end' < "$tmp")
rm -f "$tmp"
fi
printf '%s\n' "$r"
}
@@ -247,4 +294,4 @@ last_text() {
# 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
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.22.0
CODEMAN_PREAMBLE=1.30.1
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${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
+133
View File
@@ -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
View File
@@ -110,6 +110,7 @@ import {
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.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 { resolveSessionCliVersion } from './utils/cli-resolver.js';
import {
@@ -502,6 +503,8 @@ export class Session extends EventEmitter {
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
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 _taskTracker: TaskTracker;
@@ -3190,6 +3193,9 @@ export class Session extends EventEmitter {
}
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
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
@@ -3721,7 +3727,10 @@ export class Session extends EventEmitter {
const submittedPrompt = this._trackSubmit(data, options);
if (this._mux && this._muxSession) {
const sent = await this._mux.sendInput(this.id, data);
if (sent) this._emitSubmittedPrompt(submittedPrompt);
if (sent) {
this._emitSubmittedPrompt(submittedPrompt);
this._verifySubmitted(data);
}
return sent;
}
// Fallback to PTY write
@@ -3733,6 +3742,28 @@ export class Session extends EventEmitter {
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 */
private _ptyCols = 120;
private _ptyRows = 40;
+98 -6
View File
@@ -1910,6 +1910,53 @@ class CodemanApp {
this._onSessionClearTerminal(data);
}
/**
* How a buffer load that just fetched `payload` must end.
*
* A tmux pane capture is a point-in-time frame, so nothing that reached the
* browser after the response headers can already be in it. Such a load
* replays exactly that tail; discarding it drops the CLI's output for the
* rest of the load window, and its next partial redraw then lands on a frame
* the terminal never received.
*
* A `history` payload is the server's byte buffer alone: the direct-PTY
* fallback, or a mux pane whose capture came back empty. The route reads
* that buffer in the same synchronous tick it takes the capture, so it is
* current up to the route's own read and no further, which is the same
* exposure. It deliberately keeps the pre-existing discard all the same:
* both cases are rare, neither has been measured, and a duplicated Ink
* redraw is more visible than a few milliseconds of missing output.
* `capturedFromMux` below is the one line to widen if either turns out to
* matter.
*
* `headersReceivedAt` is the caller's own `performance.now()` reading from
* the moment the response arrived, compared only against other client-side
* readings, so there is no clock skew to worry about.
*
* What this cutoff does NOT cover, and there are two contributors. The
* server appends output to the byte buffer and emits it in the same tick,
* but BROADCASTS on a batch timer (8ms over WebSocket, 16 to 50ms over SSE),
* and the terminal route runs synchronously from `capture-pane` to its
* return, so a batch already pending when the capture ran leaves the server
* after the reply, arrives after `headersReceivedAt`, and is replayed
* although the capture holds it. Separately, `captureActivePaneBuffer` is
* `execSync`, which blocks the event loop for the whole capture: anything
* tmux had already painted into the pane that the server had not yet read
* from the attach PTY is in the capture too, is broadcast only after the
* reply, and replays the same way. The duplicate is one batch interval plus
* one capture wide, against a recovery window that spans the whole chunked
* write. Closing it belongs on the server: flush that session's pending
* batch before taking the capture.
*
* @param {{source?: string}} payload - The parsed `data` of a terminal response.
* @param {number} headersReceivedAt - When that response reached this client.
* @returns {{flushQueued: boolean, since: number}} Options for `_finishBufferLoad`.
*/
_bufferLoadFinishOpts(payload, headersReceivedAt) {
const capturedFromMux = payload?.source === 'mux-visible' || payload?.source === 'mux-full-history';
return { flushQueued: capturedFromMux, since: headersReceivedAt };
}
_onSessionTerminal(data) {
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
@@ -1919,7 +1966,7 @@ class CodemanApp {
// jump over the cap. Dropped data is recovered from the canonical buffer.
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
+ (this.flickerFilterBuffer?.length || 0)
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
+ (this._loadBufferQueue?.reduce((s, w) => s + w.data.length, 0) || 0)
+ (this._terminalWriteInFlightBytes || 0);
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery once the
@@ -2502,9 +2549,11 @@ class CodemanApp {
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
let headersReceivedAt = performance.now();
let data = (await res.json())?.data ?? {};
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
headersReceivedAt = performance.now();
data = (await res.json())?.data ?? {};
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
@@ -2520,7 +2569,12 @@ class CodemanApp {
const linesFromBottom = before ? Math.max(0, (before.baseY || 0) - (before.viewportY || 0)) : 0;
this.terminal.clear();
this.terminal.reset();
await this.chunkedTerminalWrite(data.terminalBuffer);
await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
undefined,
this._bufferLoadFinishOpts(data, headersReceivedAt)
);
// A tail fetch can be partial, and the banner would otherwise keep
// describing the pre-refresh buffer (#258).
this._setHistoryTruncation(sessionId, data);
@@ -2530,6 +2584,10 @@ class CodemanApp {
});
if (target === null || typeof this.terminal.scrollToLine !== 'function') this.terminal.scrollToBottom();
else this.terminal.scrollToLine(target);
// The load's own replay sampled the sticky-scroll baseline while the
// terminal sat at the bottom of a just-rewritten buffer, so the next
// flush would scroll back down and undo the restore above.
this._syncStickyScrollBaseline();
// Re-position local echo overlay at new prompt location
this._localEchoOverlay?.rerender();
// Resize PTY to match actual browser dimensions (critical for OpenCode
@@ -2556,6 +2614,7 @@ class CodemanApp {
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
try {
const res = await fetch(`/api/sessions/${data.id}/terminal`);
const headersReceivedAt = performance.now();
const termData = (await res.json())?.data ?? {};
this.terminal.clear();
@@ -2565,7 +2624,12 @@ class CodemanApp {
// (markers don't help here - this is a static buffer reload, not live Ink redraws)
const cleanBuffer = termData.terminalBuffer.replace(DEC_SYNC_STRIP_RE, '');
// Use chunked write to avoid UI freeze with large buffers (can be 1-2MB)
await this.chunkedTerminalWrite(cleanBuffer);
await this.chunkedTerminalWrite(
cleanBuffer,
TERMINAL_CHUNK_SIZE,
undefined,
this._bufferLoadFinishOpts(termData, headersReceivedAt)
);
}
// Fire-and-forget resize — don't block on it
@@ -5862,7 +5926,12 @@ class CodemanApp {
parsedAt,
bufferLength: parsedBufferLength,
completed,
} = await this.chunkedTerminalWrite(buffer, TERMINAL_CHUNK_SIZE, sessionId);
} = await this.chunkedTerminalWrite(
buffer,
TERMINAL_CHUNK_SIZE,
sessionId,
this._bufferLoadFinishOpts(payload, headersReceivedAt)
);
timing.resetAndParseMs = parsedAt - replayStartedAt;
if (!completed || this.activeSessionId !== sessionId) return;
// Keep shell tab restores bounded too. A user-triggered full-history pull
@@ -5880,6 +5949,12 @@ class CodemanApp {
const delta = parsedBufferLength - rowsBefore;
if (delta > 0) this.terminal.scrollToLine(delta);
else this.terminal.scrollToTop();
// The load's own replay sampled the sticky-scroll baseline while the
// terminal sat at the bottom of a just-rewritten buffer, so the next
// flush would scroll back down and undo the restore above. This path is
// reached only from a scroll-up gesture, so being dragged down is the
// exact opposite of what the user asked for.
this._syncStickyScrollBaseline();
timing.totalMs = performance.now() - requestStartedAt;
this._recordTerminalLoadTiming(timing);
} catch {
@@ -6321,6 +6396,15 @@ class CodemanApp {
}
const data = (await res.json())?.data ?? {};
const bodyParsedAt = performance.now();
// How this load must end, decided here because `chunkedTerminalWrite` is
// what actually ends it for a non-empty buffer. A tmux pane capture is a
// point-in-time frame, so nothing that reached the browser after the
// response headers can already be in it. Replay exactly that tail;
// discarding it drops the CLI's output for the rest of the load window,
// and its next partial redraw then lands on a frame the terminal never
// received. `since` keeps the pre-capture events dropped, because the
// capture does hold those and replaying them would duplicate output.
const finishOpts = this._bufferLoadFinishOpts(data, headersReceivedAt);
_crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
let freshResetAndParseMs = 0;
@@ -6347,7 +6431,8 @@ class CodemanApp {
const { parsedAt: freshParsedAt } = await this.chunkedTerminalWrite(
data.terminalBuffer,
TERMINAL_CHUNK_SIZE,
bufferLoadOwner
bufferLoadOwner,
finishOpts
);
freshResetAndParseMs = freshParsedAt - replayStartedAt;
if (this._isStaleSelect(selectGen)) {
@@ -6397,7 +6482,14 @@ class CodemanApp {
// COD-144: when the load painted nothing, FLUSH the queued events instead of
// discarding — a new session's prompt arrives only as a queued SSE event.
if (this._isLoadingBuffer) {
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
// Only reached when the write was skipped. COD-144 lives here: a new
// session's first prompt exists only as a queued event that predates the
// response, so an empty paint replays its queue WHOLE rather than from
// the header timestamp.
this._finishBufferLoad(
bufferLoadOwner,
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
);
}
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
+93 -21
View File
@@ -3159,6 +3159,38 @@ Object.assign(CodemanApp.prototype, {
return buffer.viewportY >= buffer.baseY - 2;
},
/**
* Re-take the sticky-scroll baseline from where the viewport now sits.
*
* `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before it queues
* data, and `flushPendingWrites` scrolls to the bottom off that sample. A
* buffer load that replays its queue samples at the worst possible moment:
* `_finishBufferLoad` runs inside `chunkedTerminalWrite`, before its promise
* resolves, with the terminal freshly reset and rewritten, so the sample is
* always true. A caller that then restores the reader's position would have
* that restore undone by the next flush.
*
* `_onSessionNeedsRefresh` and `_maybeRefetchFullHistory` restore a position
* and both call this, so their baseline describes the position they chose.
*
* The other two load paths do not call it, for different reasons.
* `_onSessionClearTerminal` resets and rewrites with no scroll afterwards,
* so the sampled true is already the truth there. `selectSession` does NOT
* end at the bottom, whatever its `scrollToBottom()` after the write
* suggests: it ends at `scrollToLastNonEmptyLine()`, which targets
* `lastNonEmptyLine - rows + 2` and therefore parks ABOVE `baseY` whenever
* the replayed frame keeps trailing blank rows, which a full capture does on
* purpose. Its baseline is a stale true. What decides whether that matters
* is the sticky snap in `flushPendingWrites`, and since de864e7d that snap
* fires only when the flush found the viewport already at the bottom
* (`preserveViewportY === null`), which a parked selectSession viewport is
* not. Do not read the absent call here as a claim that selectSession lands
* at the bottom.
*/
_syncStickyScrollBaseline() {
this._wasAtBottomBeforeWrite = this.isTerminalAtBottom();
},
// Record manual scroll gestures so sticky-scroll can give an upward scroll a
// short grace window (see _hasRecentUserScrollUp). A downward scroll that
// lands back at the bottom clears the suppression immediately.
@@ -3295,7 +3327,11 @@ Object.assign(CodemanApp.prototype, {
// to prevent interleaving historical buffer data with live SSE data.
// This is critical: interleaving causes cursor position chaos with Ink redraws.
if (this._isLoadingBuffer) {
if (this._loadBufferQueue) this._loadBufferQueue.push(data);
// Each entry records when it arrived. A flush of a tmux-capture load
// replays only what arrived after the capture; without the timestamp it
// would have to replay the whole queue, duplicating the events the
// capture already contains. See _finishBufferLoad's `since`.
if (this._loadBufferQueue) this._loadBufferQueue.push({ at: performance.now(), data });
return;
}
@@ -3809,9 +3845,14 @@ Object.assign(CodemanApp.prototype, {
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 32KB)
* @param {string} [loadOwner] - Load token to finish under
* @param {{ flushQueued?: boolean, since?: number }} [finishOpts] - Passed to
* `_finishBufferLoad`. This method ends the load for every non-empty buffer,
* so a caller that wants the queue replayed has to say so HERE; the call in
* `selectSession` only runs when the write was skipped entirely.
* @returns {Promise<{parsedAt: number, bufferLength: number, completed: boolean}>} Parse marker snapshot
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner, finishOpts) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
@@ -3824,7 +3865,7 @@ Object.assign(CodemanApp.prototype, {
completed,
});
if (!buffer || buffer.length === 0) {
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, finishOpts);
resolve(parseSnapshot());
return;
}
@@ -3838,7 +3879,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.write(cleanBuffer, () => resolve(parseSnapshot()));
// The write is now ordered in xterm's queue. Release live output before
// parsing completes; subsequent writes stay behind it without being lost.
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, finishOpts);
return;
}
@@ -3869,7 +3910,7 @@ Object.assign(CodemanApp.prototype, {
);
resolve(result);
});
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, finishOpts);
return;
}
@@ -3883,15 +3924,49 @@ Object.assign(CodemanApp.prototype, {
});
},
/**
* Open a buffer load: live terminal events are queued from here until
* `_finishBufferLoad` decides what to do with them. Returns the load token the
* finish call must present; a stale token makes that call a no-op.
*
* @param {string} [owner] Reuse an existing token to re-enter the same load
* (see below); omit it to start a new one.
* @returns {string} The load token.
*/
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
// opens it again under the SAME owner when it starts writing. Resetting the
// queue on that second call would throw away everything that arrived during
// the fetch, which on the capture path is output no buffer holds. Re-entering
// one load keeps its queue; a genuinely new load still starts empty.
const reentering = this._bufferLoadOwner === loadOwner && Array.isArray(this._loadBufferQueue);
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
if (!reentering) this._loadBufferQueue = [];
return loadOwner;
},
/**
* Complete a buffer load: unblock live SSE writes.
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
*
* By default queued SSE events are DISCARDED, not flushed. For an established
* session the loaded buffer from the API is the source of truth up to the
* response timestamp; SSE events queued during the fetch+write overlap already
* appear in that buffer, so flushing them writes duplicate data (especially Ink
* cursor-up redraws), corrupting the terminal display.
* session whose buffer came from the server's accumulated byte history, that
* history is the source of truth up to the response timestamp; SSE events
* queued during the fetch+write overlap already appear in it, so flushing
* them writes duplicate data (especially Ink cursor-up redraws), corrupting
* the terminal display.
*
* A tmux PANE CAPTURE is the exception, and the reason `since` exists. A
* capture is a point-in-time frame taken part-way through the fetch, so it is
* the source of truth only up to CAPTURE time — not up to the response. Every
* event that arrives between the capture and the end of the chunked write is
* queued and, under a plain discard, lost outright: nothing re-fetches, and
* the CLI's next partial redraw lands on a frame the terminal never received.
* The caller passes the response's own arrival time as `since` so exactly
* that tail is replayed and the pre-capture events stay dropped.
*
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
@@ -3905,17 +3980,10 @@ Object.assign(CodemanApp.prototype, {
* After unblocking, new SSE/WS events deliver subsequent output normally.
*
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
* @param {{ flushQueued?: boolean, since?: number }} [opts] When `flushQueued`
* is true, replay queued events whose arrival timestamp is at or after
* `since` (default 0, meaning the whole queue).
*/
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
return loadOwner;
},
_finishBufferLoad(owner, opts) {
if (owner !== undefined && this._bufferLoadOwner !== owner) {
return false;
@@ -3926,9 +3994,13 @@ Object.assign(CodemanApp.prototype, {
this._bufferLoadOwner = null;
// COD-144: replay (rather than discard) queued live events when the load
// painted nothing — the queued prompt is the only content a new session has.
// A tmux-capture load replays too, but only the tail: `since` cuts the queue
// at the moment the capture stopped being able to contain what arrived.
if (opts?.flushQueued && queued && queued.length) {
for (const data of queued) {
this.batchTerminalWrite(data);
const since = typeof opts.since === 'number' ? opts.since : 0;
for (const entry of queued) {
if (entry.at < since) continue;
this.batchTerminalWrite(entry.data);
}
}
return true;
+180
View File
@@ -0,0 +1,180 @@
/**
* @fileoverview Output arriving after a pane capture survives the buffer load.
*
* `batchTerminalWrite` queues live terminal events while a buffer load runs,
* and `_finishBufferLoad` discards that queue by default. That is right when
* the loaded buffer is the server's accumulated byte history, which is current
* up to the response. A tmux pane capture is current only up to CAPTURE time,
* so anything arriving between the capture and the end of the chunked write is
* queued and then dropped, with nothing scheduling a re-fetch.
*
* The queue now stamps each entry with its arrival time, and a capture load
* replays the tail that arrived after the response headers. These drive the
* real client in chromium: the event is injected from inside the response's
* own `json()` call, which is the one place guaranteed to land after the
* headers and before the chunked write.
*
* Port: 3256 (capture load window)
*
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.browser.test.ts
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3256;
const BASE_URL = `http://localhost:${PORT}`;
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(PORT, false, true); // testMode
await server.start();
browser = await chromium.launch({ headless: true });
}, 60_000);
afterAll(async () => {
await browser?.close();
await server?.stop();
}, 30_000);
/**
* Select the session with the terminal fetch stubbed, injecting one live event
* from inside `json()`. Returns how many terminal rows carry the marker, so a
* flush that replays too much fails as loudly as one that replays nothing.
*/
async function runLoad(page: Page, sessionId: string, source: string): Promise<number> {
return page.evaluate(
async ({ sid, src, marker }) => {
const app = (
window as unknown as {
app: {
selectSession: (id: string, o?: object) => Promise<void>;
_onSessionTerminal: (e: { id: string; data: string }) => void;
terminal: {
buffer: {
active: {
length: number;
getLine: (i: number) => { translateToString: (t: boolean) => string } | undefined;
};
};
};
};
}
).app;
const realFetch = window.fetch.bind(window);
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(typeof input === 'string' ? input : ((input as Request).url ?? input));
if (!url.includes('/terminal')) return realFetch(input as RequestInfo, init);
return Promise.resolve({
ok: true,
status: 200,
// `selectSession` timestamps the headers the moment this promise
// resolves, then calls json(). Injecting here puts the event after
// that timestamp and inside the load window, which is exactly the
// gap a pane capture cannot cover.
json: async () => {
app._onSessionTerminal({ id: sid, data: `\r\n${marker}\r\n` });
return {
success: true,
data: {
terminalBuffer: '\x1b[1;1Hcaptured frame line one\r\n',
status: 'idle',
fullSize: 512,
retainedBytes: 512,
truncated: false,
truncationReason: null,
source: src,
captureCols: 80,
captureRows: 24,
},
};
},
}) as unknown as Promise<Response>;
}) as typeof window.fetch;
try {
await app.selectSession(sid);
await new Promise((r) => setTimeout(r, 1200));
const buf = app.terminal.buffer.active;
let hits = 0;
for (let i = 0; i < buf.length; i++) {
if (buf.getLine(i)?.translateToString(true).includes(marker)) hits += 1;
}
return hits;
} finally {
window.fetch = realFetch;
}
},
{ sid: sessionId, src: source, marker: MARKER }
);
}
async function openSession(page: Page): Promise<string> {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
// xterm loads from /vendor, so the terminal appears a beat after the app.
// Without it every buffer assertion below would throw rather than compare.
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
timeout: 30_000,
});
return page.evaluate(async () => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-load-window-test' }),
});
const body = await res.json();
return body.data?.session?.id ?? body.data?.id ?? body.id;
});
}
describe('output emitted during a capture load', () => {
let context: BrowserContext;
let page: Page;
afterAll(async () => {
await context?.close();
});
it('reaches the terminal exactly once when the buffer came from a pane capture', async () => {
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
expect(sessionId).toBeTruthy();
// Exactly once. The cutoff exists so the flush cannot also replay events the
// payload already carried, which would double the output rather than heal it.
expect(await runLoad(page, sessionId, 'mux-visible')).toBe(1);
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
await context.close();
}, 60_000);
it('stays dropped when the buffer came from the accumulated byte history', async () => {
// The byte history already contains everything up to the response, so
// replaying the queue on top of it would duplicate the output — most
// visibly Ink's cursor-up redraws. The discard has to survive this fix.
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
page = await context.newPage();
const sessionId = await openSession(page);
// Without this, a failed create passes the zero-hit assertion below
// vacuously — nothing was loaded, so nothing was replayed.
expect(sessionId).toBeTruthy();
expect(await runLoad(page, sessionId, 'history')).toBe(0);
await page.evaluate(
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
sessionId
);
await context.close();
}, 60_000);
});
+177
View File
@@ -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);
});
});
+233 -21
View File
@@ -1,20 +1,31 @@
/**
* @fileoverview Regression tests for the buffer-load flush path (COD-144).
* @fileoverview Regression tests for the buffer-load flush path: what becomes of
* the live terminal events queued while a buffer load runs, once the load ends.
*
* Bug: newly launched Shell sessions rendered BLANK until a tab-switch. The
* buffer-load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`)
* QUEUES live SSE terminal events while `_isLoadingBuffer` is true, then on
* completion DISCARDS the queue (`_loadBufferQueue = null`). That de-dup is
* correct for an established session (the fetched buffer already contains the
* queued output, so replaying it would duplicate Ink redraws). But for a
* brand-new shell the fetch resolves BEFORE the PTY emits its prompt — the
* fetched buffer is empty and the prompt arrives only as a queued event, which
* then gets discarded → blank terminal.
* Two rules, each from a real bug.
*
* Fix: `_finishBufferLoad(owner, { flushQueued })` REPLAYS the queued events
* through `batchTerminalWrite()` (after `_isLoadingBuffer` is cleared, so they
* write through normally) ONLY when the load painted nothing. The default path
* (no opts) still discards, preserving de-dup for established sessions.
* COD-144: newly launched Shell sessions rendered BLANK until a tab-switch. The
* load path (`selectSession` → `_beginBufferLoad`/`_finishBufferLoad`) queues
* live events while `_isLoadingBuffer` is true and used to DISCARD the queue on
* completion. Right for a buffer built from the server's byte history (the
* queued output is already in it, so replaying it duplicates Ink redraws),
* wrong for a brand-new shell whose fetch resolves BEFORE the PTY emits its
* prompt: the prompt arrived only as a queued event and was thrown away. A
* caller that knows the load painted nothing passes `{ flushQueued: true }`
* and the queue is REPLAYED through `batchTerminalWrite()` after
* `_isLoadingBuffer` is cleared, so the events write through normally.
*
* #436: a tmux pane capture is current only as of the instant `capture-pane`
* ran, so everything the CLI printed between the capture and the end of the
* chunked write was queued and dropped, and its next partial redraw landed on
* a frame the terminal never received. Queue entries now carry their arrival
* time and `_finishBufferLoad` takes a `since` cutoff, so a capture load
* replays exactly the tail that arrived after the response headers. All four
* fetch-and-write paths take that policy from one helper,
* `_bufferLoadFinishOpts`, and a static scan below pins each of them to it,
* because the same fix had already been written into one path out of four,
* twice. A path that replays and then restores a scroll position re-takes the
* sticky-scroll baseline (`_syncStickyScrollBaseline`), pinned the same way.
*
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
* box; see connection-indicator.test.ts). We extract the REAL
@@ -56,10 +67,10 @@ type BufferLoadApp = {
_bufferLoadSeq: number;
_bufferLoadOwner: string | null;
_isLoadingBuffer: boolean;
_loadBufferQueue: string[] | null;
_loadBufferQueue: { at: number; data: string }[] | null;
batchTerminalWrite: (data: string) => void;
_beginBufferLoad: (owner?: string) => string;
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean;
_finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean; since?: number }) => boolean;
};
/**
@@ -84,10 +95,57 @@ function makeApp() {
return { app, writes };
}
/** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */
function pushWhileLoading(app: BufferLoadApp, data: string) {
// Mirrors batchTerminalWrite's queue branch: if loading, push to the queue.
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data);
/**
* A stub carrying the REAL `batchTerminalWrite` on top of the real begin/finish
* methods, so a replay samples the sticky-scroll baseline exactly as it does in
* the browser. The terminal is a fake whose `buffer.active` the test moves by
* hand, which is what a caller's `scrollToLine` does to a real one.
*/
function makeScrollApp() {
const buffer = { viewportY: 0, baseY: 100 };
const app = {
buffer,
terminal: { buffer: { active: buffer } },
sessions: new Map(),
activeSessionId: null,
pendingWrites: [] as string[],
writeFrameScheduled: false,
_wasAtBottomBeforeWrite: false,
_bufferLoadSeq: 0,
_bufferLoadOwner: null as string | null,
_isLoadingBuffer: false,
_loadBufferQueue: null as { at: number; data: string }[] | null,
_scheduleTerminalWriteFlush: vi.fn(),
batchTerminalWrite: mixin.batchTerminalWrite as (data: string) => void,
isTerminalAtBottom: mixin.isTerminalAtBottom as () => boolean,
_syncStickyScrollBaseline: mixin._syncStickyScrollBaseline as () => void,
_beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'],
_finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'],
};
return app;
}
/**
* Slice one class method out of app.js, from its header to the next method's.
*
* Bounding the slice matters: the two methods checked below are not followed by
* a JSDoc block, so a scan for the next comment would run on into unrelated
* code and match its scroll calls instead of theirs.
*/
function methodBody(source: string, method: string): string {
const start = source.search(new RegExp(`^ {2}(?:async )?${method}\\(`, 'm'));
expect(start, `${method} not found in app.js`).toBeGreaterThan(-1);
const next = /^ {2}(?:async )?[A-Za-z_$][\w$]*\(/m.exec(source.slice(start + 1));
return next ? source.slice(start, start + 1 + next.index) : source.slice(start);
}
/**
* Simulate a live SSE event arriving while a buffer load is in progress.
* Mirrors batchTerminalWrite's queue branch, which stamps each entry with its
* arrival time so a flush can replay only the tail (see the `since` tests).
*/
function pushWhileLoading(app: BufferLoadApp, data: string, at = performance.now()) {
if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push({ at, data });
}
describe('buffer-load flush (COD-144)', () => {
@@ -153,11 +211,165 @@ describe('buffer-load flush (COD-144)', () => {
// State untouched — still loading, queue intact, nothing replayed.
expect(app._isLoadingBuffer).toBe(true);
expect(app._bufferLoadOwner).toBe('real-owner');
expect(app._loadBufferQueue).toEqual(['queued']);
expect(app._loadBufferQueue).toEqual([{ at: expect.any(Number), data: 'queued' }]);
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
// ── The tmux-capture tail: `since` ──
//
// A pane capture is a point-in-time frame taken part-way through the fetch, so
// it holds what arrived BEFORE the capture and nothing after. selectSession
// passes the response's arrival time as `since`, which splits the queue at
// exactly that line: pre-capture events are already painted and must stay
// dropped, post-capture events exist nowhere else and must be replayed.
it('flushes only the entries at or after `since`', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-since');
pushWhileLoading(app, 'already-in-the-capture', 100);
pushWhileLoading(app, 'arrived-at-the-headers', 200);
pushWhileLoading(app, 'arrived-after-the-headers', 300);
app._finishBufferLoad(owner, { flushQueued: true, since: 200 });
// The pre-capture event stays dropped; the boundary entry counts as after.
expect(writes).toEqual(['arrived-at-the-headers', 'arrived-after-the-headers']);
});
it('flushQueued without `since` still replays the whole queue', () => {
// The COD-144 path: a brand-new session's first prompt predates the
// response, so cutting the queue would drop the only content it has.
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-no-since');
pushWhileLoading(app, 'prompt', 10);
pushWhileLoading(app, 'more', 20);
app._finishBufferLoad(owner, { flushQueued: true });
expect(writes).toEqual(['prompt', 'more']);
});
it('a `since` past every entry flushes nothing', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-since-late');
pushWhileLoading(app, 'old', 10);
app._finishBufferLoad(owner, { flushQueued: true, since: 999 });
expect(writes).toEqual([]);
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
});
// ── Re-entering one load ──
//
// `selectSession` opens the load before its fetch, and `chunkedTerminalWrite`
// opens it again under the SAME owner when it starts writing. A reset on that
// second call would silently throw away everything queued during the fetch,
// which on the capture path is output no buffer holds.
it('re-entering the same load keeps what the queue already holds', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-reenter');
pushWhileLoading(app, 'arrived-during-the-fetch', 100);
// chunkedTerminalWrite re-opens the load it was handed.
app._beginBufferLoad(owner);
pushWhileLoading(app, 'arrived-during-the-write', 200);
app._finishBufferLoad(owner, { flushQueued: true, since: 50 });
expect(writes).toEqual(['arrived-during-the-fetch', 'arrived-during-the-write']);
});
it('a genuinely different load still starts with an empty queue', () => {
const { app, writes } = makeApp();
app._beginBufferLoad('load-first');
pushWhileLoading(app, 'belongs-to-the-abandoned-load', 100);
// A tab switch starts a new load under a new owner. Its events are not ours.
const second = app._beginBufferLoad('load-second');
pushWhileLoading(app, 'belongs-to-this-load', 200);
app._finishBufferLoad(second, { flushQueued: true, since: 0 });
expect(writes).toEqual(['belongs-to-this-load']);
});
// ── The sticky-scroll baseline across a replay ──
//
// `batchTerminalWrite` samples `_wasAtBottomBeforeWrite` before queueing, and
// `flushPendingWrites` scrolls to the bottom off that sample. The replay runs
// inside `chunkedTerminalWrite` before its promise resolves, with the terminal
// freshly reset and rewritten, so the sample is always true. A caller that
// then restores the reader's position would have that restore undone.
it('the replay latches the baseline true, and the viewport restore re-takes it', () => {
const app = makeScrollApp();
const owner = app._beginBufferLoad('load-scroll');
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
// The load ends with the terminal reset and rewritten, so it reads as bottom.
app.buffer.viewportY = app.buffer.baseY;
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
expect(app._wasAtBottomBeforeWrite).toBe(true);
// The caller now puts the reader back where they were reading.
app.buffer.viewportY = 40;
app._syncStickyScrollBaseline();
// The next flush must leave them there.
expect(app._wasAtBottomBeforeWrite).toBe(false);
});
it('a restore that lands back at the bottom keeps sticky scroll armed', () => {
const app = makeScrollApp();
const owner = app._beginBufferLoad('load-scroll-bottom');
pushWhileLoading(app as unknown as BufferLoadApp, 'output-after-the-capture', 100);
app.buffer.viewportY = app.buffer.baseY;
app._finishBufferLoad(owner, { flushQueued: true, since: 0 });
app._syncStickyScrollBaseline();
// A reader who was already at the bottom still wants to be carried along.
expect(app._wasAtBottomBeforeWrite).toBe(true);
});
it('both callers that restore a scroll position re-take the baseline', () => {
// The wiring lives in app.js, outside this file's vm harness. Without it the
// two methods below restore the viewport and the next flush undoes it.
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
for (const method of ['_onSessionNeedsRefresh', '_maybeRefetchFullHistory']) {
const body = methodBody(source, method);
const restoreAt = body.lastIndexOf('scrollToLine(');
const syncAt = body.indexOf('this._syncStickyScrollBaseline()');
expect(restoreAt, `${method} no longer restores a scroll position`).toBeGreaterThan(-1);
expect(syncAt, `${method} never re-takes the baseline`).toBeGreaterThan(-1);
expect(syncAt, `${method} re-takes the baseline before its restore`).toBeGreaterThan(restoreAt);
}
});
it('every path that fetches a terminal buffer and writes it asks the shared helper', () => {
// Drift guard. The first version of this fix covered one of the four paths,
// and a later pass found it still covering one of four. Nothing else in the
// gate stops a fifth path, or an inlined `{ flushQueued: true }`, from
// splitting the policy up again; the browser suite that would notice does
// not run in CI.
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
for (const method of [
'selectSession',
'_onSessionNeedsRefresh',
'_onSessionClearTerminal',
'_maybeRefetchFullHistory',
]) {
expect(methodBody(source, method), `${method} decides the flush policy itself`).toContain(
'this._bufferLoadFinishOpts('
);
}
});
it('empty queue + flushQueued is a no-op (no throw, no writes)', () => {
const { app, writes } = makeApp();
const owner = app._beginBufferLoad('load-empty');
+45
View File
@@ -355,6 +355,51 @@ describe('terminal flush budget', () => {
expect(app._bufferLoadOwner).toBe(null);
});
// ── Which payloads end their load by replaying the queue ──
//
// A pane capture is current only up to capture time, so the tail that arrived
// after the response exists nowhere else and has to be replayed. The server's
// accumulated byte history is current up to the response, so replaying on top
// of it would duplicate output. `_bufferLoadFinishOpts` is the one place that
// decides this, for all four paths that fetch a terminal buffer and write it.
it('replays the tail for a visible-pane capture', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({ source: 'mux-visible' }, 1234)).toEqual({
flushQueued: true,
since: 1234,
});
});
it('replays the tail for a full-history capture', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({ source: 'mux-full-history' }, 1234)).toEqual({
flushQueued: true,
since: 1234,
});
});
it('discards the queue for the accumulated byte history', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({ source: 'history' }, 1234).flushQueued).toBe(false);
});
it('discards the queue for a payload that names no source', () => {
// Fails toward the safe answer: a duplicated Ink redraw corrupts the screen,
// while a dropped tail is repaired by the CLI's next full repaint.
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
expect(app._bufferLoadFinishOpts({}, 1234).flushQueued).toBe(false);
expect(app._bufferLoadFinishOpts(undefined, 1234).flushQueued).toBe(false);
});
it('does not snap back to bottom during Codex Working redraws right after the user scrolls up', () => {
const { app } = loadTerminalUiHarness('codex');
const scrollToBottom = vi.fn();