Skip to content

fix(input): make sure a prompt sent through the API actually leaves the composer - #455

Merged
Ark0N merged 2 commits into
masterfrom
fix/skill-preamble-enter-retry
Sep 19, 2026
Merged

Ark0N merged 2 commits into
masterfrom
fix/skill-preamble-enter-retry

Conversation

@Ark0N

@Ark0N Ark0N commented Sep 19, 2026

Copy link
Copy Markdown
Owner

What broke

Claude Code 2.1.277 (auto-installed on 2026-09-18) changed how a fresh pane treats input: the composer accepts typed text the moment it paints, but ignores Enter for roughly the first 30 to 50 seconds. Before that version the same key sequence had worked for weeks.

Codeman delivers a programmatic prompt as send-keys -l <text> followed 50 ms later by send-keys Enter, and every caller that then waits for the turn (send-and-wait on the input route, the agent skill's sendwait, cron, Ralph, the maintainer bot) assumes the Enter landed. Under 2.1.277 it does not: the prompt sits in the composer with 0 tokens, the transcript stays empty, and the wait burns its whole timeout on a turn that never started.

Measured on 2026-09-19 through the input route on a throwaway session: readiness at 8 s, text plus Enter at 8 s stranded, a bare Enter at 28 s still stranded, a bare Enter at 51 s submitted. The maintainer bot lost twelve reviews in a row overnight this way, each waiting out a 40-minute deadline "without a report".

What changes

Server, the general fix. src/session-submit-verifier.ts is a small pure module: promptStillInComposer(screen, prompt, glyph) reads the LAST line that starts with the CLI's own composer glyph (capabilities.workDetect.promptGlyph, Claude's , Codex's ) and says whether it still holds the head of the prompt, whitespace removed. It is the last glyph line on purpose, because Claude Code echoes a submitted prompt with the same glyph higher up in the transcript. SubmitVerifier runs that check on a 2 s to 60 s schedule after a write and presses Enter again at every check that still finds the prompt. It stops on an empty composer (taken), on other text (the user's or the CLI's, never ours), and does nothing at all for a pane with no composer line (a shell, a direct-PTY session, a CLI whose glyph is not declared), since a blind Enter from a path every programmatic sender shares could confirm a dialog nobody asked about. A newer write replaces the schedule, so an old prompt can never submit a new one, and teardown cancels it. Session.writeViaMux arms it for every mux write that carried a \r. A prompt that was taken costs exactly one pane capture at 2 s.

Skill, for servers that predate this. skills/codeman/preamble.sh gets _composer_text, which reads the rendered pane the way _trust_key already does and returns the last line with all whitespace removed, including the no-break space Claude Code draws after the glyph (stripped by its bytes, since BSD sed has no \xHH). sendwait now uses it after its first 20-second wait. The long re-wait (a tagged duplicate of the original frame, so the server re-waits without retyping) is registered first and kept open in the background for the rest of the call, and beside it the loop reads the composer and sends another \r about every ten seconds while the prompt's head is still there, up to twelve times. That shape matters: wait signals have no history, so a stop that fires while no wait is open (during a composer read between two short waits) is lost and the next wait runs its whole timeout on a turn that already ended. The first version of this loop did exactly that, twice, before the wait moved to the background. Prefix matching goes through "${c#"$head"}" so a * or ? in a prompt cannot act as a glob. CODEMAN_PREAMBLE moves from 1.22.0 to 1.30.1 (preamble header, SKILL.md's §0 loader and version checks, reference/recipes.md), so an agent whose cached preamble predates this falls back to the fresh copy. The §0 heredoc in SKILL.md and the plugins/codeman/ mirror are regenerated (both byte-pinned by tests).

Docs. The single-line-prompts gotcha in CLAUDE.md records the window, the verifier, and that the shift+tab footer means "painted", never "accepting Enter". A patch changeset.

Verified

  • tsc, lint, format, test/session-submit-verifier.test.ts (the schedule, the cap, every stop condition, the glyph, ANSI, the no-break space, cancellation, a throwing Enter), test/agent-skill.test.ts and test/plugin-manifest.test.ts; full npm test green.
  • Server side, end to end: a second Codeman instance started from this branch (CODEMAN_INSTANCE, own port and tmux socket), one throwaway Claude session, one POST /api/sessions/:id/input with text plus \r and no client-side retry of any kind. The server log shows the verifier re-sending Enter and the prompt being taken.
  • Skill side, end to end against the production instance: the new preamble sourced into a shell, readiness at 1 s, one sendwait. The trace shows two composer reads (stranded, then empty), one extra Enter, and sendwait returning signal: stop at 36 s with the answer in the transcript.
  • Same loop live in the maintainer bot since 03:44 UTC: the first review after its restart stranded at 20 s and 30 s and was taken on the second re-send.

Not in this PR

  • writeViaMux still sends text and Enter 50 ms apart; with the verifier behind it that is fine once the CLI is warm and self-correcting when it is not.
  • The DeepSeek Harness path gets the same treatment for free (its composer glyph is also ), but it was not re-measured here.

…he 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>
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>
@Ark0N
Ark0N merged commit 4754362 into master Sep 19, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 19, 2026
…hole tree

A full review of the release tree found seven things, and four of them were mine.

**The gate was red, and I put it there.** Splitting `confirmed` into `confirmedContext`
and `confirmedSwap` changed the wire field without moving three assertions that check
it: `custom-model-one-shot-launch.test.ts` and two in `custom-model-run-menu-ui.test.ts`
(the swap modal and the context modal, each of which already receives exactly the right
per-question flag). Moved, with the titles.

**Worse, my own tests for the split never ran.** The four cases in
`session-custom-model.test.ts` that exist specifically to pin it call `mockRunning()`,
which was declared inside a sibling `describe`, so they threw a ReferenceError during
setup. The split would have shipped with no passing server-side coverage while the gate
reported the failure as four broken tests rather than as four tests that were never
written. `mockRunning` is hoisted to the outer describe.

**The submit verifier pressed Enter into shell panes.** `#455`'s SubmitVerifier resolved
its composer glyph as `promptGlyph ?? '❯'`, and only claude and codex declare one, so
the other eight modes fell back to claude's `❯`. That is also starship's default shell
prompt, and pure's, and spaceship's, and p10k lean's. On such a shell the line
`❯ npm run build` sits on screen for as long as the command runs, the verifier reads it
as an unsubmitted prompt, and re-presses Enter into the running program's stdin up to
nine times on its 2s..60s schedule. Mostly a stray newline; not harmless against a y/N
prompt, `read -p`, an installer or a pager, where it takes the default. The module's own
fileoverview already stated the rule this broke. Now `?? ''`, which
`promptStillInComposer()` already treats as inert, so the verifier runs only for a CLI
that actually declares a composer.

**My #451 dedent removal left a count behind**: "Two rules keep it honest" introducing
three numbered rules.

The rest is documentation the split outran. `confirmedContext`/`confirmedSwap` appeared
in no doc at all, while `docs/api-reference.md` (the SemVer-covered contract) still told
an integrator to retry with `confirmed: true` for both questions, which is precisely the
thing the split exists to stop. Documented there, in `docs/custom-model-endpoints.md`
and in CLAUDE.md. The custom-model changeset gained the split and the `CLAUDE_CONFIG_DIR`
multi-user consequence, both user-visible and both previously absent, and #454's gained
the one exception to its own claim: a Custom Endpoints launch ignores the Instance count
stepper and always starts one session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants