Skip to content

fix(cli-mode): deliver large prompts via temp file + never lose the prompt on spawn failure#30

Merged
miguelrisero merged 15 commits into
mainfrom
fix/cli-long-prompt
Jul 8, 2026
Merged

fix(cli-mode): deliver large prompts via temp file + never lose the prompt on spawn failure#30
miguelrisero merged 15 commits into
mainfrom
fix/cli-long-prompt

Conversation

@miguelrisero

@miguelrisero miguelrisero commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Problem

Creating a CLI-mode workspace with a large initial prompt (e.g. tens of KB of pasted Chrome history) silently dropped the prompt and opened an empty TUI.

Root cause (two independent bugs):

  • Transport: cli_bootstrap() single-quoted the whole prompt into the tmux new-session command. tmux 3.4 rejects any client command past ~16KB ("command too long", exit 1) — verified empirically at ~16,324 bytes. Every CliPromptArg shape (Positional/Flag/StdinPipe) was affected; quote-heavy prompts hit it sooner.
  • Recovery: the failure was invisible. create_session returns Ok when the tmux client spawns; the client's exit-1 milliseconds later was never checked, and the terminal route cleared pending_cli_prompt immediately after that Ok — destroying the only copy. The WS then dropped, the frontend reconnected, and the bootstrap's --continue fallback launched a fresh empty TUI.
  • The loop supervisor's send_cli_keys hit the same ~16KB send-keys -l ceiling and merely logged the failure while still marking the wake-up fired.

What changed

Transport A (initial prompt) — temp file + pane-shell expansion. cli_bootstrap() now takes prompt_file: Option<&Path>. create_session writes the peeked prompt to a private 0600 file (asset_dir()/cli-prompts/<workspace>.txt) and the bootstrap reads it back:

  • Positional: vk_p="$(cat 'file')"; rm -f -- 'file'; {base} "$vk_p"
  • Flag: same with {base} {flag} "$vk_p"
  • StdinPipe: cat 'file' | {base}; rm -f -- 'file'

The tmux command is now O(1) in prompt size. The prompt never re-enters shell quoting (path single-quoted; content only expands inside double quotes). Per-arm small-prompt semantics are byte-identical (leading-dash guard and StdinPipe trailing newline moved into the file contents). The file self-deletes once consumed and is also removed on kill_cli_tmux_session/reaper and on spawn-failure recovery.

Transport B (send path). send_cli_keys now stages texts ≥4KB through a namespaced tmux buffer — load-buffer -b vk_prompt_<wsid> - (text on stdin, no argv limit) + paste-buffer -d -p (bracketed paste, so embedded newlines don't submit early) + the existing separate Enter. Texts <4KB keep the live-verified send-keys -l path.

Recovery — never destroy the prompt.

  • The route polls cli_tmux_session_exists (~5×200ms) before clearing the parked prompt. If the session never appears: leave it parked, delete the temp file, and send a red TerminalMessage::Error ("your prompt is saved and will be delivered on the next attach").
  • Oversized (>100KB) Positional/Flag prompts exceed Linux MAX_ARG_STRLEN, so they are delivered post-launch by paste (via send_cli_keys) once the session is confirmed up, and cleared only on successful delivery.
  • The loop supervisor re-parks the prompt via set_pending_cli_prompt when a wake-up delivery fails (mirrors the session-gone branch).
  • Updated the now-inaccurate comments in terminal.rs and session.rs.

How verified

  • cargo test -p local-deployment — 60 passed. New/updated unit tests cover: bootstrap length O(1) vs prompt size; path single-quoted / expansion double-quoted; per-arm file forms (Positional/Flag/StdinPipe); cli_prompt_file_content quoting semantics (verbatim content, leading-dash guard, StdinPipe newline, blank→None); cli_prompt_fits_inline cap; resume precedence ignores the prompt file.
  • cargo check -p local-deployment -p server and cargo clippy -p local-deployment -p server — clean (server required building the local-web dist for rust-embed).
  • tmux 3.4 integration on a throwaway socket (-L exec_scratch, test_* sessions): a 40KB Positional prompt through the generated file-transport bootstrap → the pane received the full 41,536 bytes byte-exact. The send path (load-buffer - + paste-buffer -d -p of 40KB into a stty raw; cat pane) delivered all 40,000 bytes; the only transformation is tmux's standard LF→CR paste translation (833 LF → 833 CR), which bracketed-paste TUIs handle.

Risks

  • The $(cat file) bootstrap form is re-verified for the claude/codex spec shapes in unit tests, but live TUI bracketed-paste-on-Enter semantics for the >100KB paste path could not be verified end-to-end in this environment (no authenticated agents). The path never loses the prompt: it stays parked until delivery is confirmed.
  • paste-buffer -p behavior depends on the TUI enabling bracketed paste; only ≥4KB loop-continuation and >100KB initial prompts take this path.
  • Deployed instance at /opt needs a rebuild+restart to pick this up.

ship-it battery

Council round 2 (code-audit, engine mixed: Codex ×4 seats + Claude cross-check) returned BLOCK from all four Codex seats; extracted and resolved below. A Codex xhigh backstop then swept the diff to convergence (CodeRabbit CLI can't auth non-interactively → Codex is the substitute). Shell-injection was cleared by every seat.

Council R2 BLOCK findings

  • [FIXED] node/npm-wrapped agent gate false-negative (perf, P1). cli_pane_agent_running only checked the pane root + direct children; codex runs as #!/usr/bin/env node that spawns the native codex as a grandchild (sh → node → codex, verified), so codex delivery never confirmed → baked prompts replayed, >100KB prompts never delivered. Now walks the whole pane process subtree from one ps snapshot; exact program-name match preserved (fallback shells / vim/npm still can't satisfy the gate).
  • [FIXED] loop re-park session-scoped vs delivery workspace-scoped (security + perf, P1). Re-park only checked the latest session row's slot while delivery peeks the workspace's newest pending prompt; a continuation could park on a newer empty row and deliver ahead of an older user prompt. repark_prompt now guards on a workspace-scoped peek. The live-send path also peeks before sending so a wake-up can't reach the agent ahead of a parked prompt.
  • [FIXED] re-park failure still marked the wake-up fired (dx, P1). A transient DB error during re-park dropped the wake-up entirely. repark_prompt returns ReparkOutcome; transient failures leave the wake-up pending (retry), and MANUAL wake-ups stay pending when a prompt is already parked instead of being silently dropped.
  • [REJECTED] racing claim-loser strands a baked prompt (all 4 seats, P1). A paste-recovery was implemented and then reverted: the codex backstop showed it can paste into the losing attach's doomed --continue leg and false-confirm via the shared process name, CAS-clearing an undelivered prompt — a loss window that violates the paramount never-lose invariant. The prompt is instead left parked (CAS-guarded) and delivered on the next attach: slower in that rare double-first-attach race, but never lost. deferred_prompt_pending and fresh_launch were also made robust so a session dying before new-session -A can't route a deferred paste into a --continue leg either.
  • [REJECTED] unbounded StdinPipe can false-negative → replay (maintainability, P1). Only the non-live amp agent is StdinPipe; it requires a multi-MB prompt for cat to still be streaming at the ~7.5s confirm timeout, and the CAS clear bounds the consequence to a single re-delivery. Live agents (claude/codex) are Positional. Adding StdinPipe-specific confirmation is scope expansion beyond the locked decisions.

Codex xhigh backstop (3 sweeps → NO ACTIONABLE FINDINGS)

  • [FIXED] ps comm parsed as the full remainder after pid/ppid (macOS full-path comms with spaces).
  • [FIXED] send-keys -l -- <text> guards prompts starting with - (verified: -l without -- fails "unknown flag").
  • [FIXED] fresh_launch uses the bootstrap's exact is_uuid resume predicate (Uuid::parse_str).
  • [FIXED] deferred_prompt_pending = !resume_will_apply (robust to a live session exiting before attach).
  • [FIXED] prompt file forced to 0600 after open (Claude cross-check P3; defense-in-depth atop the 0700 dir).
  • [REJECTED] pure-JS npm-shim agent detection: claude is a native ELF here (found directly); a hypothetical pure-JS install has no process named after the agent anywhere (comm=node, argv=cli.js), so no process-inspection gate can confirm it — a pre-existing design limitation, not reliably fixable, not exercised by the verified native install.

Merge with main

origin/main (#28 terminal-resize-newline, squash a22aa055) merged in; conflicts in pty.rs/terminal.rs reconciled keeping BOTH intents — #28's AttachInputTripwire + hex_dump + redaction + cols×rows logging, and #30's temp-file transport + deferred-clear + re-park. tripwire_session now derives from workspace_id via cli_tmux_session_name.

Verification

cargo test -p local-deployment -p server -p db (db 8 / local-deployment 71 / server 43, all pass), cargo clippy, cargo fmt — clean. CI check-runs green by SHA. Final head: 7a8033d3 (merge bcdd32c5; R2 fix commits 9197cd4d, 572085f6, 050ed84f, c10841ed, f04c888a, e6040b25, 7a8033d3).

…rompt on spawn failure

The initial CLI prompt was single-quoted into the tmux new-session command,
which tmux 3.4 rejects past ~16KB ("command too long"). The failure was
invisible: create_session returned Ok when the tmux client merely spawned, and
the route cleared pending_cli_prompt immediately after — so the prompt was
destroyed while the pane fell back to an empty TUI. The loop supervisor's
send_cli_keys hit the same ~16KB send-keys ceiling.

Transport: cli_bootstrap now reads the prompt from a private 0600 file
(asset_dir/cli-prompts/<workspace>.txt) via `vk_p="$(cat 'file')"; ... "$vk_p"`,
making the tmux command O(1) in prompt size. The prompt is never re-shell-quoted
(path single-quoted; content only ever expands double-quoted). Each CliPromptArg
arm keeps identical small-prompt semantics; the file self-deletes once consumed
and is also removed on kill/reaper. send_cli_keys stages texts >=4KB through a
namespaced tmux buffer (load-buffer - + paste-buffer -d -p) with no argv ceiling.

Recovery: the route now confirms the tmux session actually exists (poll
~5x200ms) before clearing the parked prompt; if it never appears the prompt
stays parked, the temp file is removed, and a red terminal error tells the user
it is saved for the next attach. Oversized (>100KB) Positional/Flag prompts are
delivered post-launch by paste instead of being baked past MAX_ARG_STRLEN. The
loop supervisor re-parks the prompt when a wake-up delivery fails.
…fails

If write_cli_prompt_file() failed while baking a small/mid initial prompt
(full or read-only FS, or a permissions problem creating cli-prompts/), the
bootstrap silently fell through to continue_launch() — an empty TUI on a
fresh worktree — and, because the tmux session came up healthy, terminal.rs
cleared the parked prompt: the user's only copy was destroyed, gated on an
I/O error rather than tmux's old 16KB limit. This violated the 'never
destroy the prompt' invariant.

create_session now fails the spawn with a dedicated PtyError::PromptStageFailed
when there is prompt content to deliver but its file cannot be staged, instead
of dropping it. terminal.rs's create_session Err path then returns before the
prompt-clear, leaving the DB copy parked for the next attach and surfacing the
same red recovery notice as the 'session never came up' branch (now a shared
CLI_PROMPT_PARKED_NOTICE constant so the two paths can't drift). Any partial
prompt file is removed so a torn write can't leave a stale prompt on disk.
…mpt routing

Address three review nits in the long-prompt transport:
- terminal.rs: the "session never came up" recovery branch returned without
  close_session, leaking the PtyService map entry (one per failed attach).
- pty.rs paste_via_tmux_buffer: a failed stdin write returned without reaping
  the load-buffer child; kill+await it so it can't linger as a zombie.
- Extract the baked-vs-deferred prompt routing into a pure route_initial_prompt
  helper and unit-test it (blank->None, small->Baked untrimmed, oversized->
  Deferred trimmed, StdinPipe->Baked, Unsupported->Deferred).
…ing first-attaches

Council round 1 (P0/P1) on the long-prompt transport:

- Baked prompts: tmux session existence is not a delivery ack — a missing
  agent binary leaves a healthy-looking session while the command -v guard
  skips the launch arm, and the old flow then cleared the parked DB copy
  (prompt lost in an orphaned file). The bootstrap's rm-at-hand-off is now the
  acknowledgement: the route polls for the prompt file to disappear (<=4s)
  before clearing, and StdinPipe launches rm inside the producer group
  ({ cat f; rm -f -- f; } | agent) so pipes ack at consumption too, not at
  agent exit. Verified on a scratch tmux socket (file survives missing-binary
  pane; self-deletes at hand-off while the agent still runs).

- Deferred (>100KB) prompts: a flat 750ms sleep could paste into the
  missing-binary fallback shell (prompt executed as shell input) or a
  still-booting TUI (canonical-mode truncation), then clear the DB copy.
  Delivery now waits for a non-shell process in the pane (tmux #{pane_pid} +
  ps/pgrep; #{pane_current_command} is useless here — it reports the outer
  default-shell for the pane's whole life, verified empirically), stable
  across two polls, bounded at ~10s; unready panes leave the prompt parked.

- Racing first-attaches: both could pass the no-session-yet gate, restage the
  same prompt file (truncate can tear the winner's in-flight cat) or paste an
  oversized prompt twice. CliPromptDelivery is an in-process exclusive claim,
  taken before the peek and held through confirm-then-clear; the losing attach
  simply attaches without the prompt.

- wait_for_cli_session widened to 10x250ms, and the session-never-came-up
  branch now runs kill_cli_tmux_session so a session that squeaks in after the
  window can't strand the parked prompt behind the session-exists gate.

- Delivery confirmation moved to a background task so the terminal starts
  streaming immediately instead of blanking behind the polls.

- Hygiene: prompt file removed when create_session fails after staging;
  cli-prompts/ dir created 0700; per-send sequence-suffixed tmux buffers plus
  delete-buffer when a paste fails; the Flag spec value is shell-quoted like
  every other emitted word.
…ery, simplify pass

Codex xhigh review fixes:
- clear_pending_cli_prompt is now a compare-and-swap on the exact delivered
  value: a newer prompt parked mid-confirmation (loop wake-up re-parked while
  an initial prompt was being confirmed) can no longer be destroyed by the
  older delivery's clear. Superseded clears log and leave it parked.
- A parked prompt behind a LIVE tmux session or a resume launch is no longer
  stranded: attaches now deliver it by paste into the running agent (readiness
  gate + claim + confirmed clear), so an unconfirmed earlier delivery or a
  re-parked wake-up retries on every attach instead of waiting for the session
  to die — and resume precedence no longer permanently blocks re-parked
  wake-up prompts (they arrive as follow-ups after the resume launch).
- The baked-prompt ack now requires the staged file to be gone AND a non-shell
  process owning the pane, closing the consumed-but-never-executed window
  (agent exec fails after command -v passes) that file-gone alone would have
  mistaken for delivery.

Simplify pass (behavior-preserving):
- PtyCommand::TmuxCli carries workspace_id instead of a session name that was
  reverse-parsed back into one (impossible-error branch deleted).
- PromptDelivery bundles workspace/clear-session/peeked/deferred/claim so a
  claim without a prompt is unrepresentable; too_many_arguments allow dropped.
- PtyError::PromptStageFailed is a unit variant whose display IS the shared
  recovery notice; active_resume_id() is the single owner of the resume-wins
  predicate for both the staging gate and the bootstrap; the read-and-delete
  bootstrap stage is emitted once for Positional/Flag; route_initial_prompt
  always carries trimmed text; loop_supervisor re-park has one owner
  (repark_prompt, now logging its failures); cli_tmux_session_exists reuses
  tmux_ok; the pane probe runs pgrep before ps and is_shell_command owns comm
  normalization; internal helpers dropped from the crate's public surface.
…e, guarded re-park, workspace peek

Recall-mode review (5 correctness finders + conventions) on the delivery
state machine:

- Deferred paste gates on THE agent we launched (tmux #{pane_pid} tree comm
  == spec.program, 15-byte comm truncation handled) instead of any non-shell
  process — a user running vim/npm inside the missing-binary fallback shell
  can no longer receive (and effectively destroy) a prompt meant for the
  agent. Re-verified right before the paste (the agent can die inside the
  grace window and drop the pane to a shell that would EXECUTE the prompt)
  and after it (an agent that exits immediately discarded the paste with its
  tty; delivery is not confirmed).

- Fresh deferred launches start a bare TUI instead of --continue||fresh: the
  doomed --continue first leg of a brand-new workspace lived just long enough
  to pass the readiness gate, swallow the paste, exit, and let the CAS clear
  destroy the prompt.

- Multi-line texts always ride the bracketed-paste buffer transport;
  send-keys -l types newlines as Enter, submitting at the first line while
  reporting success. Enter is retried once and a text-delivered/Enter-failed
  send now counts as delivered (re-pasting would double the text).

- Loop wake-up re-parks park only into an EMPTY slot (guarded UPDATE):
  continuation boilerplate can never overwrite a parked-but-undelivered user
  prompt. Wake-up sends take the CliPromptDelivery claim so they can't
  interleave paste+Enter pairs with an in-flight delivery; a busy claim
  defers the wake-up to the next tick.

- The parked-prompt peek is workspace-scoped (creation parks on the CLI-first
  session, wake-ups on the latest, the attach may resolve a third) so a
  prompt parked on a sibling session row is never stranded.

- kill_cli_tmux_session kills before removing the prompt file (a first rm
  could yank the file mid-bootstrap and let file-gone+agent-up confirm a
  delivery that never happened); the session-never-came-up branch no longer
  kills a late-appearing session (it may be running real work; a parked
  prompt behind a live session is deliverable now); blank parked prompts are
  CAS-cleared instead of re-probed forever; the baked confirm window widened
  to 30x250ms and drops the never-consumed file on timeout; the claim
  registry recovers from mutex poisoning instead of silently disabling all
  deliveries; the embedded tmux conf pins default-shell /bin/sh so the POSIX
  bootstrap survives fish/csh login shells (applied to running servers too).

Tests: in-memory SQLite coverage for the CAS clear (match/supersede/no-op),
if-empty park, and workspace-scoped peek; unit tests for the fresh-deferred
bootstrap, follow-up routing, paste-transport predicate, and expected-agent
comm matching.
…subtree walk

The delivery gate cli_pane_agent_running only inspected the pane root and its
direct children, and its doc claimed a shebang exec sets comm to the script
basename. Both are wrong for node-wrapped agents: codex ships as a
'#!/usr/bin/env node' launcher whose comm is 'node' and which spawns the native
codex as a GRANDCHILD (sh -> node -> codex, verified on a scratch socket). The
gate therefore never confirmed codex delivery, so every baked codex prompt was
delivered via argv but left parked -> replayed on the next fresh launch, and
>100KB codex prompts (deferred paste) never delivered at all.

Walk the whole pane process subtree from a single 'ps' snapshot (pure, unit-
tested helper) and match the exact program name at any depth; still refuse the
intermediate 'node' and any fallback-shell program, so a user's vim/npm can't
satisfy the gate. Also force 0600 on the prompt file after open (a pre-existing
looser-perm file would otherwise keep its mode; the 0700 dir is the real
control, this is defense in depth) and drop the now-unused process_comm helper.
… session creation

Two racing first-attaches to a brand-new workspace both run 'tmux new-session
-A'. The prompt-carrying attach (claim winner) can compute fresh_launch=true and
bake the prompt into its bootstrap, yet the claim LOSER (promptless) can reach
new-session first and create the session with a fresh/continue bootstrap; the
winner's '-A' attach then just joins that session and its baked bootstrap is
ignored. Confirmation then timed out, dropped the file, and left the prompt
parked until a later attach -- the live agent started with no initial prompt.

confirm_or_recover_baked_prompt now, on confirmation timeout, pastes the prompt
into whatever agent owns the pane IF the staged file is still on disk. For argv
agents (Positional/Flag) the bootstrap rm's the file before exec, so a surviving
file proves our bootstrap never ran and the prompt was never argv-delivered --
pasting can't double-deliver. StdinPipe/Unsupported carry no fallback (a
lingering file there can mean cat is mid-stream). File cleanup moves to the
recovery function so the confirmed path never yanks a file out from under a
consumer.
…nding on transient DB error

Two gaps in the wake-up re-park recovery:

1. repark_prompt only checked the LATEST session row's empty slot
   (set_pending_cli_prompt_if_empty), but attach delivery peeks the workspace's
   newest pending prompt across ALL session rows. With an older session holding
   an undelivered user prompt and a newer empty session, a wake-up parked
   continuation boilerplate on the newer row and it delivered BEFORE the user
   prompt. Guard on a workspace-scoped peek so a prompt parked anywhere blocks
   re-park (the user prompt delivers first; the loop re-detects its banner).

2. Both failure branches called the best-effort repark_prompt (returning ()) and
   then marked the wake-up fired, so a transient DB error during re-park dropped
   the wake-up entirely -- neither delivered nor parked nor pending. repark_prompt
   now returns a ReparkOutcome; only Failed (a transient DB error) leaves the
   wake-up pending for the next tick, mirroring the existing capture-failure
   branch. Handled/NoSession stay final.
…r-lose

The codex backstop found the paste-recovery added a prompt-LOSS window: when a
losing racing first-attach won 'new-session -A' with a promptless bootstrap, the
recovery pasted the prompt into whatever agent owned the pane — but the loser's
'--continue || fresh' leg and the eventual fresh agent share a process name, so
the paste could land on the dying '--continue' leg yet be 'confirmed' by the
fresh one, CAS-clearing a prompt no agent ever received.

Revert to leaving the prompt parked when baked confirmation fails: in that rare
double-first-attach race the next attach delivers it as a follow-up paste into
the live agent — slower, but never lost, which is the invariant that matters.
confirm_baked_prompt_consumed documents the deliberate choice.
…e walk

pane_subtree_has_program split each ps line on whitespace and took the third
token as comm, truncating a comm that contains spaces. macOS 'ps -o comm='
reports a full executable path, which can contain spaces (e.g.
'/Applications/My App/codex'), so an agent there would never match and its
delivery would never confirm. Split off only pid and ppid and keep the whole
remainder as comm (normalize_comm still reduces it to the basename).
…lready parked

The workspace-scoped re-park guard skipped parking whenever any prompt was
already parked and the callers then marked the wake-up fired. For a limit-kind
continuation that self-heals (the loop re-detects its banner), but a MANUAL
wake-up is the user's explicit one-time prompt and was silently dropped, with no
second slot to hold it. repark_prompt now returns AlreadyParked distinctly and
wakeup_stays_pending keeps manual wake-ups pending (retry when the slot frees)
while still letting limit continuations fire.
… backstop

- fresh_launch now uses the bootstrap's exact resume predicate (Uuid::parse_str
  == is_uuid). A non-UUID resume id is not resumed by the launch, so treating it
  as 'resuming' (the old resume_session_id.is_none() check) routed the prompt as
  a follow-up paste into continue_launch's doomed '--continue' leg, risking loss.
- The loop supervisor's live-send path now peeks the workspace's parked prompt
  before send_cli_keys and defers the wake-up if one is queued, so a wake-up can
  never reach the agent ahead of an older undelivered prompt (the same
  'parked prompt delivers first' invariant the re-park path enforces).
# Conflicts:
#	crates/local-deployment/src/pty.rs
#	crates/server/src/routes/terminal.rs
…g-dash text

Two more delivery-loss paths found by the final backstop sweep:
- send_cli_keys' small-text path ran 'tmux send-keys -l <text>' with no '--',
  so a prompt starting with '-' (e.g. '-rf ...') was parsed as an unknown
  send-keys flag and delivery silently failed (verified: 'unknown flag -f').
  Add '--' before the text so it is always treated as a literal key.
- deferred_prompt_pending was gated on fresh_launch (session absent at check
  time). A live session can exit between that check and 'new-session -A'; the
  freshly created pane would then run continue_launch's doomed '--continue' leg
  and the deferred paste could land on it and be lost. Request the bare-TUI
  bootstrap for any non-resume deferred prompt (ignored by '-A' when the
  session survives, so it's harmless in the common case).
@miguelrisero miguelrisero merged commit 972e862 into main Jul 8, 2026
7 checks passed
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.

1 participant