Skip to content

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

Merged
Kitenite merged 3 commits into
mainfrom
preset-send-timing-v2
May 28, 2026
Merged

Kitenite merged 3 commits into
mainfrom
preset-send-timing-v2

Conversation

@Kitenite

@Kitenite Kitenite commented May 28, 2026 •

Copy link
Copy Markdown
Member

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 ([bug] Workspace freezes on missing Node version prompt (no way to answer y/N) #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.

Written for commit 5df3c3e. Summary will update on new commits.

Review in cubic

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

queueInitialCommand was holding the preset command behind shellReadyPromise,
so any user whose shell never emits OSC 133;A (stale wrapper install, exec'd
shell, .zshrc blocking on stdin, broken precmd hook) waited the full 15 s
SHELL_READY_TIMEOUT_MS before their preset started.

Write the initial command immediately: the PTY's stdin buffer holds bytes
until the shell reads them, so the command runs as soon as rc files finish.
Also drop the timeout from 15 s to 3 s — with initialCommand no longer
gated, the only remaining job of the timeout is flushing partial OSC 133;A
prefix bytes the scanner holds back from output, which doesn't need 15 s.

Adds a reproduction test that spawns /bin/bash without the Superset wrapper
on disk (the most common stale-install symptom). Before the fix the test
took 15 257 ms; after, 142 ms.
@ghost

ghost commented May 28, 2026 •

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 3 individual chapters for you:

Title
1 Remove gating of initial commands on shell readiness
2 Reduce shell readiness timeout duration
3 Add regression test for prompt command execution
Open in Stage

Chapters generated by Stage for commit 5df3c3e on May 28, 2026 2:16am UTC.

@coderabbitai

coderabbitai Bot commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ac90b4f-d22b-4f83-9395-48dfbe918fa8

📥 Commits

Reviewing files that changed from the base of the PR and between 13c7cb2 and 5df3c3e.

📒 Files selected for processing (2)
  • packages/host-service/src/terminal/terminal.adoption.node-test.ts
  • packages/host-service/src/terminal/terminal.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/host-service/src/terminal/terminal.adoption.node-test.ts
  • packages/host-service/src/terminal/terminal.ts

📝 Walkthrough

Walkthrough

Reduce OSC 133;A scanner timeout from 15s to 3s and write CreateTerminalSessionOptions.initialCommand immediately to the PTY (if the session is live). Add an adoption test that forces /bin/bash and verifies the initialCommand creates a sentinel file quickly (measured <5s; up to 10s allowed).

Changes

Shell-readiness timeout and immediate initialCommand execution

Layer / File(s) Summary
Timeout and immediate initialCommand write
packages/host-service/src/terminal/terminal.ts
SHELL_READY_TIMEOUT_MS set to 3000ms to control flushing held partial OSC 133;A prefix bytes when a full marker never arrives. queueInitialCommand now writes the normalized initialCommand immediately to PTY stdin when the session isn't exited; removed doc comment stating the command was queued behind shellReadyPromise.
Initial command execution adoption test
packages/host-service/src/terminal/terminal.adoption.node-test.ts
New end-to-end test forces /bin/bash, supplies an initialCommand that writes a sentinel file, measures elapsed time from session creation to sentinel detection (asserts <5s), allows up to 10s for the sentinel to appear, disposes the session on success, and restores the account shell in a finally block.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 A bashful rabbit taps the key,
The PTY hums — no waiting plea,
Sentinel sprouts in a wink, not long,
Timeout trimmed, the flow stays strong,
Hooray — the test hops on along! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: stopping the gating of preset launches on OSC 133;A marker to eliminate the 15s stall.
Description check ✅ Passed The pull request description is comprehensive and complete, covering all required template sections: summary, context, testing, and design decisions with exceptional detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch preset-send-timing-v2

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@capy-ai

capy-ai Bot commented May 28, 2026

Copy link
Copy Markdown

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR removes the shellReadyPromise gate from queueInitialCommand, writing the preset command to the PTY immediately on session creation instead of waiting for the OSC 133;A shell-ready marker. It also reduces SHELL_READY_TIMEOUT_MS from 15 s to 3 s since the timeout now only governs how long the scanner withholds partial-marker bytes from output, not how long users wait for their command to land.

  • terminal.ts: queueInitialCommand drops the .then() deferral and writes synchronously; the timeout constant is trimmed to 3 s with an updated comment explaining its new, narrower purpose.
  • terminal.adoption.node-test.ts: Adds a regression test that spawns /bin/bash without a Superset wrapper so the marker never fires, then asserts the sentinel file appears in under 3 s — reproducing the reported 15 s stall and verifying it is gone.

Confidence Score: 4/5

Safe to merge; the core write-path change is minimal and well-understood, with the main risk being cosmetic echo artifacts that were already present in v1.

The production code change is a two-line simplification with no new state and no behavioral regression — bytes written before the shell's read loop are safely buffered by the kernel PTY layer, matching v1's documented approach. The new regression test validates the fix end-to-end but has a marginal flakiness window: it asserts elapsed < 3000 ms after a waitFor that itself has a 3000 ms timeout, so on a slow host the session-creation overhead alone could push elapsed past the threshold.

The timing assertion in terminal.adoption.node-test.ts (lines 192–198) deserves a second look to decouple the waitFor timeout from the promptness threshold.

Important Files Changed

Filename Overview
packages/host-service/src/terminal/terminal.ts Removes the shellReadyPromise gate from queueInitialCommand and reduces SHELL_READY_TIMEOUT_MS from 15s to 3s; change is well-scoped and the remaining redundant exited guard is harmless.
packages/host-service/src/terminal/terminal.adoption.node-test.ts New regression test for the promptness fix; the elapsed < 3000 assertion shares the same boundary as the waitFor timeout, creating a marginal flakiness window on slow CI.

Sequence Diagram

sequenceDiagram
    participant Renderer
    participant HostService
    participant PTY
    participant Shell

    Renderer->>HostService: createTerminalSession(initialCommand)
    HostService->>PTY: spawn shell
    HostService->>PTY: pty.write(cmd) immediately
    PTY-->>Shell: bytes buffered in kernel stdin buffer
    Shell->>Shell: source rc files
    Shell->>PTY: enters read loop
    PTY->>Shell: cmd delivered from buffer
    Shell->>Shell: execute cmd
    alt Wrapper present
        Shell->>HostService: shell-ready marker in output
        HostService->>HostService: resolveShellReady ready, clear timeout
    else Timeout after 3s
        HostService->>HostService: resolveShellReady timed_out
    end
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
packages/host-service/src/terminal/terminal.adoption.node-test.ts:192-198
**Timing assertion shares its boundary with `waitFor`'s timeout**

`elapsed` is measured after `await waitFor(..., 3000)`, which itself can block for up to 3000 ms. On a slow CI host where bash startup + `echo` takes, say, 2 800 ms, `createTerminalSessionInternal` might add another 200 ms before `waitFor` even starts polling — pushing `elapsed` past the 3 000 ms threshold and causing a spurious failure. Splitting the `waitFor` budget (e.g., 5 000 ms) from the timing threshold (e.g., 3 000 ms from the moment `waitFor` begins) would decouple the two checks and eliminate this flakiness window.

### Issue 2 of 2
packages/host-service/src/terminal/terminal.ts:802-804
**Redundant `exited` guard after removing the async gap**

The outer guard at line 792 (`if (session.initialCommandQueued || session.exited) return`) and the inner `if (!session.exited)` wrap were both needed in the old code because the `.then()` callback introduced an async interval during which `session.exited` could flip. Now that the write is synchronous there is no scheduling gap between the two checks, so the inner guard is unreachable in any new-failure scenario. It is harmless, but it may mislead future readers into thinking a race still exists here.

Reviews (1): Last reviewed commit: "fix(host-service): stop gating preset la..." | Re-trigger Greptile

Comment on lines +192 to +198
await waitFor(() => fs.existsSync(sentinelFile), 3000);
const elapsed = Date.now() - start;
console.log(`[repro] initialCommand executed in ${elapsed}ms`);
assert.ok(
elapsed < 3000,
`expected initialCommand to run promptly, took ${elapsed}ms`,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Timing assertion shares its boundary with waitFor's timeout

elapsed is measured after await waitFor(..., 3000), which itself can block for up to 3000 ms. On a slow CI host where bash startup + echo takes, say, 2 800 ms, createTerminalSessionInternal might add another 200 ms before waitFor even starts polling — pushing elapsed past the 3 000 ms threshold and causing a spurious failure. Splitting the waitFor budget (e.g., 5 000 ms) from the timing threshold (e.g., 3 000 ms from the moment waitFor begins) would decouple the two checks and eliminate this flakiness window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/terminal/terminal.adoption.node-test.ts
Line: 192-198

Comment:
**Timing assertion shares its boundary with `waitFor`'s timeout**

`elapsed` is measured after `await waitFor(..., 3000)`, which itself can block for up to 3000 ms. On a slow CI host where bash startup + `echo` takes, say, 2 800 ms, `createTerminalSessionInternal` might add another 200 ms before `waitFor` even starts polling — pushing `elapsed` past the 3 000 ms threshold and causing a spurious failure. Splitting the `waitFor` budget (e.g., 5 000 ms) from the timing threshold (e.g., 3 000 ms from the moment `waitFor` begins) would decouple the two checks and eliminate this flakiness window.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +802 to +804
if (!session.exited) {
session.pty.write(cmd);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant exited guard after removing the async gap

The outer guard at line 792 (if (session.initialCommandQueued || session.exited) return) and the inner if (!session.exited) wrap were both needed in the old code because the .then() callback introduced an async interval during which session.exited could flip. Now that the write is synchronous there is no scheduling gap between the two checks, so the inner guard is unreachable in any new-failure scenario. It is harmless, but it may mislead future readers into thinking a race still exists here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/terminal/terminal.ts
Line: 802-804

Comment:
**Redundant `exited` guard after removing the async gap**

The outer guard at line 792 (`if (session.initialCommandQueued || session.exited) return`) and the inner `if (!session.exited)` wrap were both needed in the old code because the `.then()` callback introduced an async interval during which `session.exited` could flip. Now that the write is synchronous there is no scheduling gap between the two checks, so the inner guard is unreachable in any new-failure scenario. It is harmless, but it may mislead future readers into thinking a race still exists here.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@github-actions

github-actions Bot commented May 28, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

- queueInitialCommand: drop redundant `if (!session.exited)` — outer guard
  at the top of the function already returns early when exited, and the
  write is now synchronous so there is no async gap to revisit.
- adoption test: decouple the 3 s timing assertion from the waitFor budget
  it sat next to. waitFor now has 10 s of headroom; the timing assertion
  uses 5 s — still well under the pre-fix 15 s stall and gives CI room.
@Kitenite
Kitenite merged commit b68caa4 into main May 28, 2026
16 of 17 checks passed
@Kitenite
Kitenite deleted the preset-send-timing-v2 branch May 28, 2026 07:48
saddlepaddle added a commit that referenced this pull request Jul 24, 2026
…e dropped forever (#5927)

PR #5774 reintroduced marker-gating of initialCommand (regressing #4963)
and removed the timeout fallback, so any shell whose OSC 133;A marker
never reaches the scanner (exec'd shells, ZDOTDIR overrides) left preset
and agent launches queued behind a promise that never resolves — an empty
terminal with no command run.

Keep #5774's first-prompt wait (real fix for direnv/devenv startup hooks
consuming PTY input) but bound it at 15s, matching the v1 stack: on
expiry the readiness promise resolves as timed_out and the queued command
writes anyway. Also restore the flush of partially-matched marker bytes
the scanner withholds, and clear the timer on every pending-state exit.

Claude-Session: https://claude.ai/code/session_01KEmzD2DhH34tRA6GUuRgri
saddlepaddle added a commit that referenced this pull request Jul 29, 2026
…ite (#6026)

* fix(host-service): send initialCommand Enter as a separate delayed write

Agent and preset launches intermittently ended up typed at the prompt but
never executed: the workspace was created, the terminal opened, and the
full launch command sat in the shell's edit buffer with no agent running.

The OSC 133;A shell-ready marker fires from precmd, which runs before the
prompt is drawn and before the line editor starts reading input. Writing
`command\n` in that gap lets shell plugin init (vi-mode,
syntax-highlighting — worst under first-create load) flush the PTY input
queue mid-read: the command text survives into the editor's buffer while
the trailing newline is lost. Reproduced live via CDP on a first
workspace create after app boot; a single synthetic Enter keypress
launched the stuck agent, confirming only the newline was missing.

Strip trailing newlines from the command text and send Enter as its own
write 500ms later — as `\r`, what a real Enter key sends, bound to
accept-line in every keymap. Exactly one Enter is ever sent, so a double
run is impossible, and if startup eats the text itself the stray `\r` is
a no-op at an empty prompt — no failure mode is worse than before.

Residual hole in the #4963 → #5774 → #5927 marker-gating series.

Claude-Session: https://claude.ai/code/session_01CwWxQTQW2zLeN9hhRkQNxT

* test(host-service): expect split command/Enter writes in setup terminal test

The setup-scripts integration test asserted the old single `cmd\n` write;
initialCommand now arrives as the command text plus a separate delayed
`\r`. Assert both writes and their ordering.

Claude-Session: https://claude.ai/code/session_01CwWxQTQW2zLeN9hhRkQNxT

* style(host-service): format setup-scripts test assertion

Claude-Session: https://claude.ai/code/session_01CwWxQTQW2zLeN9hhRkQNxT

This branch was previously deployed

1 inactive deployment
preview — 5df3c3eb Deployed May 28, 2026 by Kitenite via Deploy Docs #5793
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