fix(host-service): stop gating preset launches on OSC 133;A marker

Merged
KI
5mo ago
+41-12

Summary

  • queueInitialCommand no longer waits on shellReadyPromise — the preset command is written to the PTY immediately. Users whose shell never emits the OSC 133;A marker (stale wrapper, exec'd shell, blocking .zshrc, broken precmd hook) no longer hit a guaranteed 15 s stall on every preset send.
  • SHELL_READY_TIMEOUT_MS drops from 15 s to 3 s. With initialCommand no longer gated, the only remaining job of the timeout is flushing the scanner's held partial-marker bytes, which doesn't need 15 s.

Why / Context

Users have been reporting that sending a preset in the v2 workspace takes ~15 s before anything happens. Traced to queueInitialCommand (packages/host-service/src/terminal/terminal.ts), which deferred pty.write(cmd) behind shellReadyPromise.then(...). That promise resolves either when the OSC 133;A "prompt start" marker arrives or when SHELL_READY_TIMEOUT_MS (15 s) fires.

The marker comes from our zsh/bash wrapper rc files via a precmd hook (apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts). Several real-world cases cause it to never fire:

  1. Stale install — getShellBootstrapEnv only sets ZDOTDIR to ~/.superset/zsh if .zshrc exists there. If setupAgentHooks hasn't run / failed silently, zsh sources the user's plain dotfiles and our precmd hook is never registered.
  2. exec tmux / exec screen in user .zshrc — our hook is installed in .zlogin, which runs after .zshrc. An exec in .zshrc replaces the process before .zlogin runs.
  3. .zshrc blocking on stdin — ssh-add, fnm/asdf/mise prompts, conda init license, gh auth. Shell sits at the keyboard; precmd never fires.

All three look identical from the renderer: send preset → 15 s of nothing → command finally appears.

How It Works

PTY line discipline buffers bytes written to stdin until the slave (the shell) reads them. Writing claude\n immediately at session create just puts it in that buffer; the shell consumes it once it finishes sourcing rc files and enters its read loop. v1's preset path achieves the same outcome by baking the command into shell argv (zsh -lc "<cmd>") and explicitly did not gate writes — the #3478 comment in apps/desktop/src/main/terminal-host/session.ts:870 documents that gating was previously removed because it froze workspaces.

The OSC 133;A scanner still strips the marker from output and holds partial-prefix bytes back across chunks (job preserved). Adoption still treats adopted sessions as ready (the prompt has already happened in the previous host-service lifetime).

Manual QA Checklist

Terminal Features

  • Send a preset (Claude) in v2 — command runs immediately, no 15 s wait
  • Dev mode launches without errors after bun dev
  • Agent-setup runs and updates zsh + bash wrappers on launch
  • Test on a user whose ~/.superset/zsh/.zlogin is missing or pre-fix-vintage — should now launch promptly instead of stalling
  • Test with a heavy .zshrc (oh-my-zsh + nvm + conda init) — preset still lands, no double-prompt

Workspace

  • Re-send the same preset (host-service adoption path) — still works, no re-fire of initialCommand

Testing

  • bun run lint — clean
  • bun run typecheck (host-service) — clean
  • New regression test: packages/host-service/src/terminal/terminal.adoption.node-test.ts → initialCommand runs promptly even when OSC 133;A never fires. Spawns /bin/bash (in SHELLS_WITH_READY_MARKER) with no Superset wrapper on disk so the marker never arrives. Before the change the suite took 15 257 ms (the full timeout); after, 142 ms.
  • Full terminal.adoption.node-test.ts suite (13 tests) passes — including the adoption-ordering test that confirms an adopted session does not re-fire initialCommand.

Design Decisions

  • Why drop the gate instead of matching v1's argv-based preset launch. v1 passes the preset command via zsh -lc "<cmd>" so the shell exits when the preset exits. v2 keeps an interactive shell alive after the preset for the "Run" tab and similar flows; switching to argv-based would change that behavior. Dropping the gate matches v1's write policy (#3478) without changing v2's process-lifecycle behavior.
  • Why 3 s for the timeout. With initialCommand no longer dependent on it, the timeout only governs how long the scanner withholds a partial OSC 133;A prefix from output. 3 s is overkill for that and bounded enough that the worst case is invisible.

Known Limitations

  • If a user's .zshrc consumes stdin via read (ssh-add passphrase, fnm install prompt, conda license, gpg passphrase), the preset command bytes can be consumed by that read instead of landing at the zle prompt. This was already true under the old behavior — users in this situation saw their preset command typed into the passphrase prompt anyway, just 15 s later. The principled long-term fix is to switch to argv-based preset launch (v1's pattern); not done here to keep the change minimal.
  • Cosmetic: during the brief window when the PTY is in canonical-mode echo, the kernel may echo the bytes back to the output stream before zle takes over. xterm's prompt redraw typically paints over it. v1 already accepts the same window for user-typed input during init.

Follow-ups

  • Add timing telemetry around resolveShellReady (state, shellName, elapsed) so we can confirm the distribution of marker-fire vs timeout in production.
  • Self-heal stale wrappers in createTerminalSessionInternal — check ~/.superset/zsh/.zlogin contains __superset_prompt_mark before each spawn; re-invoke createZshWrapper if not.

Open in Stage

Summary by cubic

Presets now start immediately by writing the initial command to the PTY without waiting for the OSC 133;A marker, removing the 15s stall. The shell-ready timeout drops to 3s and only flushes partial marker bytes held by the output scanner.

  • Bug Fixes

    • Stop gating queueInitialCommand on shellReadyPromise so presets run even when wrappers are stale, shells exec, or .zshrc blocks on stdin.
    • Reduce SHELL_READY_TIMEOUT_MS from 15s to 3s; scanner still strips OSC 133;A and flushes partial prefixes.
  • Refactors

    • Simplified queueInitialCommand by removing a redundant exit guard; relaxed the regression test timing (5s assert, 10s waitFor) to reduce CI flakiness.
    • Trim stale comments and docstrings around the shell-ready timeout and initial command gating.

Summary by CodeRabbit

  • Bug Fixes

    • Initial commands are now sent immediately when a terminal session starts, improving responsiveness when shell-ready signals never arrive.
    • Shell-ready detection timeout shortened (previously much longer), speeding up session startup.
  • Tests

    • Added an end-to-end regression test that verifies initial commands complete and records latency even if readiness markers never appear.

Review Change Stack