Repository navigation
Description
POST …/sessions/:id/message {keys} returns 200 and a snapshot for a keystroke that was never sent
Filed from the #249 assessment, recommendation item 2 — "Resolve key() one way or the other…
This is the memo's item 3, still open, still unfiled." #249 cannot close leaving it as the ADR's
only record.
What is still true, and what has already been fixed
The #249 memo framed this as "the console offers keystrokes and the runner accepts them into a
transcript nobody treats as an error." The Pilot half is already fixed and should not be
re-litigated: press_keys is no longer offered as a tool, and the reasoning is written at
workers/api/src/lib/coding-loop.ts:201-207 —
"the tool was still advertised, mapped to
{kind:"keys"}, routed intokey()and answered with a
normal snapshot, indistinguishable from success. So a menu ("1) Sign in with…") produced: brain
callspress_keys("Enter")→ nothing sent → unchanged pane → … The run burned all 40 decisions of
BYOK Claude and endedmax_stepshaving done nothing."
toDecision (:283-286) now converts a stale press_keys into an honest stuck, and the system
prompt says outright "You CANNOT send keystrokes" (:235).
The HTTP surface was not fixed with it. workers/api/src/routes/coding.ts:466-470:
const action: CodingActionKind =
typeof body.keys === "string"
? { kind: "keys", keys: body.keys }
: { kind: "message", text: String(body.text ?? "") };→ callRunner(conn, "/coding/act", {sessionId, action}) → CodingRuntime.act
(packages/browser-runner/src/coding/runtime.ts:240) → session.key(action.keys) →
packages/browser-runner/src/coding/headless.ts:555-557:
key(keys: string): void {
this.push(`[ignored keypress ${keys.slice(0,40)} — this session has no terminal attached]`);
}The route then returns the fresh snapshot with an ordinary 200. Nothing in the response says the
keystroke was not delivered. The only record is a line in the pane text, which a caller reading
{status, pane} has no reason to parse.
Who can reach it, measured
- The console: nobody.
CodingTab.tsx:565-577is the only sender and posts{text}.grep -rn '"keys"' agents/coder/web/src store/console/src→ no hits. - MCP: no.
coding_session_message(workers/mcp/src/index.ts:907) posts{text: message}. - The Pilot: no longer. Per above.
- The REST API: yes, to any owner of the instance, and to anything built against the documented
surface. It is instore/openapi.yaml's shape by virtue of being accepted.
So today this is a lie-shaped API with no caller rather than an active bug — which is the best
possible moment to resolve it, and the exact condition under which it gets forgotten instead.
The decision to make
The memo put it as "either the console stops offering keystrokes, or the runner stops accepting them
into a transcript nobody treats as an error." Since the console never offered them, the real fork is:
(a) Refuse — the cheap, honest answer, and my recommendation. key() is not implementable
without a PTY, so the route should say so. routes/coding.ts returns 409 with the sentence the
Pilot already uses: "this session has no terminal attached — a human needs to answer the prompt."
One branch, no runner change, and it makes the impossibility discoverable at the boundary rather
than in a pane. The runner's key() should also stop being a silent success: return a failure the
runtime propagates, so a future backend that can send keys does not inherit the pretence.
(b) Implement — node-pty inside HeadlessSession. #249's proposed ADR 0002 names this as the
only admissible route to a PTY (rule E4), because node-pty preserves the spawn env, the usage
drain (parseEngineUsage off the stream-json result event), the act parser
(noteAct/settleAct), and the resolved-credential derivation — all four of which tmux gives up.
That is real work, and #249's own value analysis says keystrokes are "the one genuine PTY-shaped
gap" with no named user asking for it.
Do (a) now. Leave (b) to the ADR. If (b) is ever built, (a) is the thing it deletes — one branch —
and until then the platform stops claiming a capability it does not have.
The third thing, which is not optional either way
interrupt is the sibling action and it does work (headless.ts:562-570 sends SIGINT). So the
answer to "the CLI is stuck on a prompt" is not "we can do nothing" — it is interrupt, or a
request_human takeover. Whatever wording the 409 carries should point at those, because a refusal
that names no alternative is how the next caller invents one.
What to do, cheapest first
-
routes/coding.ts:466-470— refuse{keys}with 409 and the "no terminal attached" sentence,
naminginterruptand takeover as what exists. -
headless.ts:555-557— makekey()report failure rather than push a line and return void, so
the runtime can propagate it. Keep the transcript line: [bug] A one-shot raw engine is judged idle by a 1.5s quiet timer and killed mid-turn — process exit is already the exact signal #391's reasoning that a swallowed
no-op is worse than a recorded one still holds. - Delete
{kind:"keys"}fromCodingActionKindonly if (a) is chosen and nothing else
routes it —coding-loop.ts:57still declares it in the decision union, and removing it there
is what stops the stale-tool path from being re-added. Checkruntime.ts:65,240together. - One test:
POST …/message {keys:"Enter"}→ 409 with that sentence;{text:"…"}unchanged.
Alternatives considered and rejected
- Leave it: harmless, nothing calls it. Rejected on the evidence in
coding-loop.ts:201-207—
something did call it, it cost a full 40-step BYOK run, and the reason the fix was made at the
brain rather than at the boundary was expedience. The boundary is where it is true. - Accept the keystroke and translate it into a message ("press Enter" → send the text "Enter").
Rejected: it invents a semantic the engine did not agree to and would put literal words into a
prompt. A no-op that reads as success is bad; a wrong action that reads as success is worse. - Restore a tmux backend so keystrokes work. Rejected in Decide: should tmux return as a selectable Coder engine backend? (real PTY + attach) #249's assessment and by the proposed
ADR 0002: a PTY is a file descriptor, tmux is a screen buffer, and tmux costs metering (Coding Engine spend is invisible in the usage ledger — the largest real cost isn't recorded #267), the
acts audit (A delegated run records its outcome but not its consequential acts — merging to main is invisible #294) and merge authority (migration 0091) to buy it. - Return 200 with
{delivered:false}. Rejected: a success status for an undelivered action is
the same defect at a lower volume, and every existing caller ignores the body's shape.
Acceptance criteria
-
POST /v1/instances/:id/coding/sessions/:sid/message {"keys":"Enter"}→ 409, with a message
naming what to do instead. -
{"text":"…"}on the same route is byte-identically unchanged, including the rules-prefix
injection and the watcher stamp. - No path from the Pilot, the console or MCP can produce a
{kind:"keys"}action. - If
key()survives on the runner, it returns a failure the runtime surfaces — a snapshot alone
is no longer the whole answer.
Regression risk
- An old runner is the one real risk.
CodingRuntime.acton a published@proagentstore/cli
older than this change still accepts{kind:"keys"}; the cloud refusing first is what makes that
moot, so the refusal must be in the API route, not only in the runner. coding-loop.ts:283-286deliberately keeps apress_keysarm for a cached/older tool list. Do not
delete it while removing the action kind — it is the thing that turns a stale call into an honest
stuckrather than anunknown tool.- The takeover path (
resume) andinterruptshare this route's neighbourhood; a 409 branch added
before the session lookup would change their error ordering. Add it aftergetSession.
Files: workers/api/src/routes/coding.ts:462-500,
packages/browser-runner/src/coding/headless.ts:545-570,
packages/browser-runner/src/coding/runtime.ts:60-70,234-250,
workers/api/src/lib/coding-loop.ts:55-60,195-207,230-240,275-292,
agents/coder/web/src/CodingTab.tsx:565-577, workers/mcp/src/index.ts:900-915.
Related: #249 (the assessment that asks for this, item 2, and proposed ADR 0002 rule E4),
#391 (process exit as the turn boundary, and why a swallowed no-op is worse than a recorded one),
#294/#267 (what a screen-buffer backend would cost), #433 (the docs that describe the split).
Activity
fix(coding): a keystroke sent to a coding session is refused at the b…
chore(cli): 0.4.45 — the runner refuses an undeliverable keystroke, a…
serge-ivo commentedon Aug 8, 2026
Shipped in
dc8ed14(+ CLI0.4.45ine65cad0) — verified live in productionOption (a), as recommended: the platform stops claiming a capability it does not have.
Verified against production, just now
The refusal names alternatives, per the ticket's "third thing, which is not optional either way" —
a refusal that names no alternative is how the next caller invents one.
Also verified live that the 404-before-409 ordering is intact: the same body against a session
id that does not exist returns
404 {"error":"Session not found"}, not the 409. That was the namedregression risk — a refusal added before the session lookup would have changed
interrupt's andresume's error ordering. The branch sits aftergetSessionand a test pins it.Acceptance criteria
{"keys":"Enter"}→ 409 naming what to do instead. Measured above.{"text":"…"}unchanged, including the rules-prefix injection and the watcher stamp — driventhrough the route rather than grepped.
{kind:"keys"}action.{kind:"keys"}is removed fromCodingActionKindinlib/coding-loop.ts.workers/mcpwasre-checked and left untouched: it posts
{text}.key()returns a failure the runtime surfaces —{delivered:false, reason}, raised as aRunnerInputErrorbyCodingRuntime.act. The transcript line is kept: [bug] A one-shot raw engine is judged idle by a 1.5s quiet timer and killed mid-turn — process exit is already the exact signal #391's reasoningthat a swallowed no-op is worse than a recorded one still holds.
Two judgement calls worth recording
press_keysarm intoDecisionis kept, exactly as the ticket warns. It is whatconverts a cached or older tool list into an honest
stuckinstead of anunknown tool.keysstays in the runner's action union, with a comment. The runner is a publishedpackage: a shape has to be recognised to be refused with the right sentence, rather than
falling into "Unknown coding action". The cloud refusing first is what makes an old runner moot,
which is why the 409 is in the API route and not only in the runner.
Reaches installed runners via
@proagentstore/cli@0.4.45, published by CI on this push(confirmed on npm). Full bar green including Playwright chromium+webkit; deploy workflows green.