fix(server): only type scripts into a shell that is at its prompt#2158
fix(server): only type scripts into a shell that is at its prompt#2158cleiter wants to merge 6 commits into
Conversation
Paseo runs paseo.json scripts by typing them into an interactive PTY. The readiness check resolved on any output or after 1.5s, which is wrong whenever shell startup prints and then blocks on input. oh-my-zsh's "Would you like to update? [Y/n]" satisfied it, so the command was typed into that prompt: read -k ate the first character and the script ran with ASEO_DEV_MANAGED_HOME=1, not PASEO_DEV_MANAGED_HOME=1. A swallowed "y" would have triggered an omz update instead. Gate injection on a real signal. The zsh integration now emits a nonce-tagged OSC 633;R marker from zle-line-init -- the moment the line editor, and nothing else, is reading stdin. precmd is too early: it runs before prompt expansion and before any precmd hook a later .zshrc adds. The hook registers lazily from precmd because the integration is sourced from .zshenv, where zle is not loaded yet, and uses add-zle-hook-widget so it composes with the user's own widget. at-prompt is tracked as current state, not history: it clears on 633;C and on exit, so reused script terminals wait instead of interleaving keystrokes into a running foreground command. On timeout the script fails rather than typing blind, and the terminal stays open holding the prompt that blocked it. Plain scripts keep their runtime entry so a re-run reuses that same shell -- answer the prompt, run again, and the command lands intact. Service scripts re-plan their port, so their terminal's env would be stale; their entry is dropped. bash/fish have no integration and keep the legacy heuristic. There is no shell-agnostic readiness signal, so each shell needs its own hook; the nonce env var is injected for every shell so one can adopt the marker without a protocol change. See docs/terminal-input-readiness.md. Verified live against real zsh through the worker terminal manager: a blocked startup no longer types, and answering it then re-running lands the full command. The same harness on bash still shows PASEO_ -> ASEO_, which is the gap above.
The readiness gate decided from the shell's filename: basename == "zsh" meant "a marker is coming". That conflates two states that look identical from outside -- a shell blocked on startup input, and a shell that will never report at all. zsh older than 5.3 has no add-zle-hook- widget, and a .zshrc can clobber ZDOTDIR or our hooks; those shells would now fail every script on a 15s timeout, where before they ran. The zsh integration even carried a comment promising the daemon "falls back to legacy behavior". Nothing implemented that: the shell had no way to say the hook was absent. So it says so. The .zshenv wrapper emits OSC 633;I;<nonce> from its first lines -- before the user's .zshenv, before .zshrc, before oh-my- zsh, so nothing user-configurable can delay it, and it only announces when it can deliver (is-at-least 5.3). Announce then silence means blocked: wait, then fail. Silence from the start means no integration: fall back to the old heuristic, which is what those shells already have. Also from review: - Nonce-tag 633;C. It is the marker that says "stop typing", so it must be as unforgeable as the one that says "start". A bare 633;C from foreign output (VS Code's own integration emits it) could strand a script. - Latch the zle hook registration only after it succeeds. Latching first turned one transient failure into a shell that never reports readiness again -- the shape of the resize latch bug in getpaseo#2059. - Key the zsh runtime dir by a hash of its contents. Every daemon writes that shared path, so an older one overwrote the newer integration and shells starting in that window lost their markers. - Drop the pre-registration prompt-state buffer. The worker subscribes before sending terminalCreated and IPC preserves order, so it was unreachable, and it leaked an entry per terminal that never registered. - Await killAndWait in the terminal test teardown. Killing without waiting raced rmSync of the shell's HOME (ENOTEMPTY). - Shorten the docs row so CLAUDE.md's table stops re-padding: 65 lines of formatting churn for one line of content. Verified live against real zsh: a blocked startup still refuses to type and keeps its terminal for the retry, and the command lands intact once the prompt is answered.
Codex review found two ways the readiness gate could still type into a shell that was not ready. Both have the same root: prompt state lives in the terminal worker, and the parent reasons about a copy of it that lags by one IPC hop. Across that gap, "has not announced yet" and "the announce is in flight" are indistinguishable, and a check-then-send pair can straddle a state change. So the parent stops guessing and asks: - getPromptState: before falling back to the legacy heuristic, re-read the state from the worker. Concluding "no integration" from a stale copy took the legacy path and retyped the original bug. The announce grace also goes 1.5s -> 5s: it only needs to cover zsh reaching the first line of our .zshenv, but being wrong types into a possibly-blocked shell, while being slow only costs latency for the rare shell with no integration at all. Scheduling on a loaded machine can exceed 1.5s. - sendInputIfAtPrompt: the worker checks and writes in the same tick against the live session. The readiness wait can only prove the shell WAS ready; a foreground command can take stdin before the write lands. A lost race now types nothing and fails with reason "raced", which keeps its terminal for the retry exactly like a timeout does. Legacy shells send unguarded. They never report a prompt, so guarding their write would mean never typing at all -- the tests caught that immediately. Two findings are documented rather than fixed, both in docs/terminal- readiness.md: preexec does not fire for readers launched by a ZLE widget (fzf on Ctrl-R), so atPrompt stays true while fzf owns stdin. zsh exposes no hook for it and the only sound signal, the pty foreground process group, is not reachable through node-pty. It is fail-open but needs someone typing in a script terminal at the instant of injection. And an announce whose hook later fails to register yields I with no R: fail-closed into the readiness timeout, never corruption. The in-flight-announce test fails with the re-read removed and passes with it, so it pins the fix rather than the implementation.
A plain terminal's tab opens the instant its pty exists, because the create RPC responds right away. Starting a paseo.json script did not: its response was deferred until the command had been typed into the shell, which the readiness gate holds back until the shell is at a prompt -- up to the 15s timeout when a shell is blocked on a startup prompt (an oh-my-zsh update ask). The tab-focus rode on that response, so a blocked start showed nothing for 15s and then surfaced an error telling the user to go answer a prompt they could not see. The terminal exists about 100ms into a script start too. So the start now hands it over the moment it is registered, through an onTerminalReady callback, and runs the readiness wait plus command send detached. The service responds from that callback, so a client focuses the tab immediately in every case -- and the connection's message loop no longer blocks for up to the readiness timeout, which it did before. A blocked shell is no longer an error. Its terminal is already on screen showing the prompt it is waiting for, and the still-running readiness wait types the command on its own once the prompt is answered. Only a failure before any terminal exists (bad config, a script already running) reports a start error; the pre-existing failure toast handles that unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
| Filename | Overview |
|---|---|
| packages/server/src/terminal/terminal.ts | Core change: adds TerminalPromptState tracking, nonce-tagged OSC 633 marker parsing, and exposes getPromptState/fetchPromptState/sendInputIfAtPrompt on TerminalSession. Subscribe-before-check ordering in emitExit correctly prevents races. The content-hash keyed ZDOTDIR prevents version-clobber races between concurrent daemon versions. |
| packages/server/src/server/worktree-bootstrap.ts | Replaces the 1.5 s output heuristic with waitForTerminalInputReadiness; introduces TerminalNotReadyError with terminalStillOpen flag for retry-friendly UX; correctly worker-round-trips the announce check to avoid treating in-flight IPC as silence. The settle() guard prevents double-resolution across all racing timers/events. |
| packages/server/src/terminal/worker-terminal-manager.ts | Correctly keeps promptState out of the info object to prevent re-registration rollback; fetchPromptState round-trips to the worker and silently updates the local copy without notifying listeners (safe because the event was already in flight and will arrive before the response). Exit clears listeners and removes record so late events are ignored. |
| packages/server/src/terminal/terminal-worker-process.ts | handlePromptStateRequest correctly handles getPromptState and sendInputIfAtPrompt atomically in the worker process. The output flush before emitting prompt-state events preserves ordering for the parent. |
| packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts | Correctly decouples terminal hand-off (via onTerminalReady/respondOnce) from the readiness wait; a TerminalNotReadyError after respondOnce is logged at debug level and does not emit a second response. The failStart path only fires when no terminal was handed over yet. |
| packages/server/src/terminal/shell-integration/zsh/.zshenv | Emits 633;I;nonce before user rc files run, gated on is-at-least 5.3 and an interactive shell check. This correctly lets the daemon distinguish no integration from blocked waiting for input. |
| packages/server/src/terminal/shell-integration/zsh/paseo-integration.zsh | Correctly uses add-zle-hook-widget (composes, does not replace) for zle-line-init; latches _PASEO_ZLE_HOOK_REGISTERED only after successful registration to avoid permanently silencing a transient failure. The nonce on C; prevents foreign OSC 633 traffic from clearing atPrompt. |
| packages/server/src/server/worktree-bootstrap.test.ts | Comprehensive behavior tests for readiness, fallback, retry, and write-race scenarios; the StubTerminalManager is well-modelled. Two tests cast their options object as never to pass test-only fields that are declared optional in the interface — the cast is unnecessary and suppresses type-checking on the call site. |
| packages/server/src/terminal/terminal.test.ts | Real PTY tests correctly verify nonce matching, nonce rejection, at-prompt clearing, and the blocked-startup case that was the root cause. Switching cleanup from session.kill() to session.killAndWait() avoids ENOTEMPTY races on HOME removal. |
| packages/server/src/terminal/worker-terminal-manager.test.ts | New tests cover prompt-state mirroring, spawn-time seeding, re-registration rollback prevention, exit forcing off-prompt, and late-event ignoring after exit. All test through the session interface, not internal records. |
Reviews (2): Last reviewed commit: "Quiet the log for a blocked script start..." | Re-trigger Greptile
The instant-focus change emits the start response from onTerminalReady, not from spawnWorkspaceScript's resolved value. session.test.ts's mock resolved a terminal but never called onTerminalReady, so no response was emitted and the assertion saw an empty message list. The mock now hands the terminal over the way the real launcher does. Missed locally because I ran only the touched test files, not session.test.ts, which injects its own spawn mock. CI caught it.
Two P2s from review.
A shell that stops on a startup prompt throws TerminalNotReadyError after
its terminal was already handed over. That is a normal interaction, but
both the service .catch and spawnWorkspaceScript's catch logged it at
error level ("Failed to start/spawn workspace script") — so every script
start under oh-my-zsh, the exact case this PR targets, would flood the
daemon log with error entries describing the user answering a prompt. The
service now returns without re-logging when the terminal was already
handed over, and spawnWorkspaceScript logs a readiness stall at debug;
only genuine failures stay at error.
Also drop a dead optional chain in the worker's sendInputIfAtPrompt: once
`sent` is true the session is non-null, so narrow it properly instead of
`session?.send`.
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
|
@paseo-bot reshape audit |
Reshape verdict
Line breakdown
Production shape
The low-level shape is good. Prompt ownership lives in the terminal, the worker performs the atomic check/write, and the fallback policy is centralized. The large test addition is mostly earned behavior coverage. The bolt-on is the handoff path. One reshapeReturn the two phases explicitly: const spawned = await spawnWorkspaceScript(options); // terminal exists
respond(spawned.terminalId, null);
void spawned.commandStarted.catch(handlePostHandoffFailure);
After that, deletion is mechanical: remove the readiness implementation and its terminal/worker registrations. In the current shape, removing instant handoff requires tracing a callback through launcher, service, and mocks. |
Linked issue
N/A - Discussed in Discord
Type of change
What does this PR do
paseo.jsonscripts are typed into an interactive PTY, and the check for"is this shell ready for keystrokes" was: has it printed anything, or has 1.5s
passed. Neither is evidence. A shell that prints and then blocks on
readsatisfies both, and gets a command typed into whatever is reading.
oh-my-zsh does exactly that on startup:
The
Panswered the prompt; the rest replayed onto the command line. The daemoncame up with a silently wrong
PASEO_DEV_MANAGED_HOME. It is luck that thefirst character was
P— a leadingywould have answered "yes, updateoh-my-zsh" instead. Nothing errors; the script just runs wrong.
Four commits, each reviewable on its own:
1. Don't type until the shell's line editor owns stdin. The zsh integration
emits three nonce-tagged OSC 633 markers:
Rfromzle-line-init(line editorhas the line — safe to type),
Cfrompreexec(a command took the foreground— not safe), and readiness is gated on them.
zle-line-init, notprecmd:precmdruns before prompt expansion and before any hook a later.zshrcadds,so a
readthere still eats the command. At-prompt is tracked as current state,cleared on
Cand on exit — which is also what makes reusing a script terminalsafe.
2. Let the shell say whether it can report readiness at all. A shell that
never reports is either blocked (must not type) or has no integration — zsh
< 5.3 has no
add-zle-hook-widget, a.zshrccan clobberZDOTDIR— wherenever typing would break every script. Both look like silence. So the
.zshenvwrapper emits
633;Ifrom its first lines, before any user rc file runs:silence before it means no integration (fall back to the old heuristic, which
is what those shells have today); silence after it means blocked (wait, then
fail). It only announces when it can deliver (
is-at-least 5.3).3. Ask the worker, don't guess from a stale copy. Prompt state lives in the
terminal worker; the daemon holds a copy that lags one IPC hop, so "hasn't
announced" and "announce in flight" look identical there. Before falling back it
re-reads via
getPromptState, and the command goes throughsendInputIfAtPrompt,which checks-and-writes in the same tick against the live session — so a
foreground command taking stdin between the check and the write can't get the
keystrokes.
4. Focus a script's terminal instantly, like a plain terminal. A plain
terminal's tab opens the instant its pty exists. A script's did not: the RPC
response was deferred until the command was typed (up to the readiness timeout
on a blocked shell), and the tab-focus rode on that response — so a blocked
start showed nothing, then an error telling you to answer a prompt you couldn't
see. The start now hands the terminal over the moment it is registered
(
onTerminalReady) and runs the readiness wait + send detached: the tab focusesimmediately in every case, the connection's message loop no longer blocks, and a
blocked shell is no longer an error — its terminal is on screen showing the
prompt, and the still-running wait types the command once you answer it. Only a
failure before any terminal exists (bad config, already running) reports an
error.
When it can't type, it doesn't. On a readiness timeout the terminal stays
open; plain scripts keep their runtime entry so re-running reuses that same
shell. Rationale, the OSC protocol, and the worker/manager ordering rules are in
docs/terminal-readiness.md.Known gaps, deliberately not closed here:
is no reliable shell-agnostic PTY readiness signal, so each shell needs its own
integration. The nonce env var is injected for every shell so a bash/fish hook
can adopt the same markers without a protocol change. Happy to follow up.
fzfon Ctrl-R):preexecdoesn't fire, soat-prompt stays true while fzf owns stdin. zsh exposes no hook; the only sound
signal is the pty's foreground process group, which node-pty doesn't expose.
Fail-open, but it needs someone typing in a script terminal at the instant of
injection.
Iwith noRand failsthe script at the readiness timeout. Fail-closed, never corruption.
How did you verify it
Reproduced the original bug first, then confirmed the fix, using a real zsh
whose
.zshrcprints and blocks onread -k 1(standing in for oh-my-zsh'supdate prompt), driving the real
spawnWorkspaceScriptthrough the real workerterminal manager. The script under test writes
$PASEO_PROBE_VALUEto a file,so what the shell parsed is visible, not just what it printed.
Before (and still, on bash — the known gap above):
After, on zsh:
Also verified live in the desktop dev app: started the
daemonscript with theomz prompt armed, saw its tab focus immediately showing the
[Y/n]prompt,answered it in the terminal, and the command ran on its own.
Automated — all green locally, plus
npm run typecheck,npm run lint,npm run formatclean.terminal.test.ts— real terminals: the marker sets at-prompt only when thenonce matches (a foreign nonce is ignored),
Cclears it, and — the one thatpins the bug — a real zsh blocked on a startup
readreports not at-promptwhile its output is already on screen.
worker-terminal-manager.test.ts— prompt state crosses the worker boundary;re-registration can't roll live state back to the spawn-time snapshot; exit
forces at-prompt false; late markers from a dead shell are ignored.
worktree-bootstrap.test.ts— nothing is typed until at-prompt; anever-announcing zsh falls back and still runs (the zsh-<5.3 case); an
announced-but-blocked shell fails instead of typing; a shell that loses its
prompt between the check and the write types nothing; an announce still in
flight to the parent does not trigger the fallback.
workspace-scripts-service.test.ts— the start responds with the terminal assoon as it exists; a blocked shell still hands the terminal over with no
error (exactly one response, not a second error over it); only a
pre-terminal failure reports an error.
Two of those I checked the honest way — reverting the fix makes the test fail and
restoring it passes — so they pin the behavior, not the implementation: the
in-flight-announce fallback, and the single-response guarantee for a blocked
start.
Before:
Terminal recording of the live desktop repro:
Screen.Recording.2026-07-17.at.12.14.19.mov
Checklist
npm run typecheckpassesnpm run lintpassesnpm run formatran (Biome)