Skip to content

fix(desktop): gate terminal stdin on shell readiness to prevent preset command loss - #2469

Merged
Kitenite merged 2 commits into
ParadevAI:mainfrom
Roshvan:fix/terminal-shell-ready-marker
Mar 19, 2026
Merged

Kitenite merged 2 commits into
ParadevAI:mainfrom
Roshvan:fix/terminal-shell-ready-marker

Conversation

@Roshvan

@Roshvan Roshvan commented Mar 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Fix terminal preset commands (e.g. claude) being lost when the shell is still initializing (direnv, devenv, nvm, conda, oh-my-zsh, etc.)
  • Session-owned shell readiness state machine gates write() — buffering stdin while the shell is pending, flushing in FIFO order once ready
  • createOrAttach() returns immediately — terminal attaches and renders as soon as PTY spawns, no blocking
  • Byte-level marker state machine prevents any marker bytes from leaking to xterm, even across chunk boundaries
  • Terminal protocol responses (DA, DSR) dropped during shell init to prevent escape fragments appearing as typed text

Problem

When a preset launches a terminal and sends a command, the command gets lost because the shell hasn't finished initializing. The PTY Spawned IPC frame fires immediately after pty.spawn() — before the shell has sourced RC files or run hook functions.

This affects any project where shell initialization takes non-trivial time:

Tool What it does at startup Typical delay
direnv / devenv Loads .envrc, evaluates Nix expressions, exports env vars 1–5s
nvm Detects .nvmrc, switches Node version, rehashes PATH 100–500ms
conda Activates environment, modifies PATH and prompt 200–800ms
oh-my-zsh Loads plugins (git, node, npm, etc.), compiles completions 200–1000ms
asdf / mise Detects .tool-versions, shims PATH entries 100–300ms
starship / powerlevel10k Renders prompt (git status, language versions) 50–500ms

Architecture

Approach: Session-owned stdin gating with OSC shell-ready marker

This follows the same pattern used by major terminal emulators for shell integration:

  • VS Code — OSC 133 sequences from precmd/PROMPT_COMMAND
  • iTerm2 — OSC 1337 and OSC 133 for shell integration
  • Warp — OSC 9001 proprietary sequences from shell hooks

We use OSC 777 (private-use range, won't conflict with existing protocols).

How it works

1. Shell wrappers (shell-wrappers.ts) inject a one-shot marker hook at the end of shell init:

  • zsh: precmd function in .zlogin — placed last in precmd_functions so it fires after direnv and all other hooks
  • bash: PROMPT_COMMAND function in rcfile — appended (not prepended) so it runs after existing hooks. Handles both scalar and array PROMPT_COMMAND (Bash 5.1+). Guards against empty PROMPT_COMMAND.
  • fish: --on-event fish_prompt handler, self-removing via functions -e

2. Session (session.ts) owns shell readiness with a state machine:

  • States: pending | ready | timed_out | unsupported
  • write() buffers user commands in preReadyStdinQueue while pending, passes through otherwise
  • Terminal protocol responses (escape sequences starting with \x1b) are dropped during init — they are stale DA/DSR responses from shell startup queries that would appear as typed text (e.g. ?62;4;9;22c)
  • Headless emulator responses (DA queries) also dropped during init — the renderer handles queries once a client attaches
  • resolveShellReady() transitions state, clears timeout, flushes queue in FIFO order
  • Marker detected via byte-level state machine that withholds potential marker bytes from output until confirmed — zero leakage to xterm even across PTY data frame boundaries
  • Timeout (15s) started in spawn(), not in terminal-host
  • Shells without markers (sh, ksh) start as unsupported — writes pass through immediately, no timeout

3. TerminalHost (terminal-host.ts) — unchanged:

  • createOrAttach() returns immediately after PTY spawn — no blocking wait
  • Terminal attaches and renders right away (user sees direnv loading in real time)
  • All stdin gating is handled by Session internally

4. Renderer (launch-command.ts) — no changes needed

Edge cases handled

  • Unsupported shell (sh, ksh): Starts as unsupported, no buffering, no timeout
  • Very slow init (>15s): Timeout fires, flushes queue, degrades to pre-fix behavior
  • Re-attach to existing session: Only new sessions have pending state
  • Session exits/killed before ready: handleSubprocessExit → resolveShellReady('timed_out') → queue flushed
  • Marker split across PTY frames: Byte-level state machine holds partial matches
  • False marker starts: Held bytes flushed as regular output on mismatch
  • DA responses from xterm: Dropped during pending (stale startup query responses)
  • DA responses from headless emulator: Dropped during pending (renderer takes over)
  • Empty PROMPT_COMMAND: Guarded in bash scalar branch
  • Bash 5.1+ array PROMPT_COMMAND: Detected via declare -p, both forms handled

Files changed

File Change
apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts SHELL_READY_MARKER constant with TSDoc
apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts One-shot marker hooks for zsh, bash, fish
apps/desktop/src/main/terminal-host/session.ts Shell readiness state machine, write gating, marker stripping, DA response dropping
apps/desktop/src/main/terminal-host/session-shell-ready.test.ts 19 focused unit tests

Test coverage (19 tests)

  • Write buffering: buffer + flush after marker, unsupported shell pass-through, DA response dropping, timeout flush
  • Marker detection: single frame, split across frames, start/end of frame, false marker starts
  • Kill/exit: subprocess exit before marker, session killed during init
  • Shell matrix: zsh/bash/fish buffered; sh/ksh/dash pass-through

Test plan

  • 19 unit tests passing
  • bun run typecheck passes
  • Open workspace with .envrc + direnv — terminal renders immediately, preset command runs after prompt
  • Test without direnv — command executes promptly
  • Test re-attaching to existing terminal — works instantly

PR History

Initial implementation

Blocked createOrAttach() with waitForShellReady() — terminal wouldn't render until shell was ready (up to 15s). Used a carry-buffer approach for marker detection.

Review feedback from @Kitenite

Identified that blocking createOrAttach() was the wrong boundary — the terminal should attach immediately, and only stdin delivery should wait. Recommended:

  • Move readiness ownership from TerminalHost into Session
  • Buffer writes in Session, not block session creation
  • Use a state machine instead of a promise for readiness
  • Fix bash PROMPT_COMMAND ordering (was prepending, should append)
  • Replace carry-buffer with a proper byte-level state machine

Refactor to address feedback

Rewrote the implementation:

  • Removed all blocking from createOrAttach() — returns immediately
  • Session now owns readiness via ShellReadyState enum + preReadyStdinQueue
  • write() gates on readiness; resolveShellReady() flushes queue
  • Byte-level marker state machine prevents any marker bytes from reaching xterm
  • Bash hook appended (not prepended) with empty guard and Bash 5.1+ array support
  • Timeout started in spawn() where it belongs

DA response fix

Discovered that headless emulator DA responses and renderer xterm protocol responses were reaching the shell during init, causing ?62;4;9;22c to appear as typed text. Fixed by:

  • Dropping emulator responses during pending state (renderer handles queries once attached)
  • Dropping all escape sequence writes (\x1b-prefixed) during pending state — they are stale responses to shell startup queries

Test suite

Added 19 focused unit tests covering all cases requested in review: write buffering, marker detection (including cross-frame splits), kill/exit before readiness, unsupported shell fallback, and DA response dropping.

@coderabbitai

coderabbitai Bot commented Mar 14, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces shell readiness detection for terminal sessions. It adds marker emission logic to shell wrappers (zsh, bash, fish), exports a marker constant, implements marker detection and input buffering in session handling, and includes comprehensive tests for buffering and marker detection behavior.

Changes

Cohort / File(s) Summary
Shell initialization markers
apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts, apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts
Adds one-shot shell-ready marker emission across zsh, bash, and fish wrappers. Each shell defines a helper function that emits an OSC escape sequence after user startup hooks complete, then removes itself to prevent repeated firings. Exports SHELL_READY_MARKER constant defining the escape sequence.
Session readiness pipeline
apps/desktop/src/main/terminal-host/session.ts
Implements shell readiness state tracking (pending/ready/timed_out) with timeout handling, marker byte detection/stripping from PTY output, and input write buffering. Queues stdin writes while shell initialization is pending, flushes them after marker detection or timeout, and resets state on session disposal.
Shell readiness tests
apps/desktop/src/main/terminal-host/session-shell-ready.test.ts
New test suite with mock subprocess/IPC utilities validating write buffering semantics, marker detection across frame boundaries, timeout behavior, shell support coverage (zsh, bash, fish vs. sh-like shells), and exit handling.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Session
    participant PTY as PTY/Shell
    participant Buffer as Input Buffer

    Client->>Session: Start terminal session
    activate Session
    Session->>PTY: Spawn shell process
    activate PTY
    
    Note over Session: Shell readiness state = "pending"
    Note over Session: Start timeout (SHELL_READY_TIMEOUT_MS)
    
    Client->>Session: Write input
    Session->>Buffer: Queue write (pending state)
    Note over Buffer: Input buffered, not yet sent
    
    PTY->>Session: Emit startup output + marker
    activate Session
    Session->>Session: Detect SHELL_READY_MARKER in data
    Session->>Session: Strip marker bytes from output
    Note over Session: Shell readiness state = "ready"
    deactivate Session
    
    Session->>PTY: Flush queued input buffer to shell
    Buffer->>PTY: Send buffered writes
    
    PTY->>Client: Return shell output (marker stripped)
    deactivate PTY
    deactivate Session
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

A shell wakes up, sends signals bright,
With markers dancing in the night,
The buffer queues and waits with care,
Till readiness blooms in the air! 🐰✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: fixing terminal stdin gating on shell readiness to prevent preset command loss during shell initialization.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the problem statement, architectural approach, implementation details, edge cases, and test plan. All required template sections are present and filled with substantial content.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

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.

Tip

You can disable sequence diagrams in the walkthrough.

Disable the reviews.sequence_diagrams setting to disable sequence diagrams in the walkthrough.

@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.

3 issues found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/main/terminal-host/session.ts">

<violation number="1" location="apps/desktop/src/main/terminal-host/session.ts:299">
P2: Shell-ready marker detection is chunk-fragile: if the marker is split across PTY data frames, readiness is never detected and session creation waits for timeout.</violation>
</file>

<file name="apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts">

<violation number="1" location="apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts:278">
P2: Handle array-valued `PROMPT_COMMAND` in Bash 5.1+; this scalar assignment can discard existing prompt hooks.</violation>
</file>

<file name="apps/desktop/src/main/terminal-host/terminal-host.ts">

<violation number="1" location="apps/desktop/src/main/terminal-host/terminal-host.ts:159">
P2: This unconditionally waits for a shell-ready marker that some supported shells (`sh`/`ksh`) never emit, causing a guaranteed 15s startup delay for those sessions.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/terminal-host/session.ts`:
- Around line 293-312: The current PtySubprocessIpcType.Data handler only
searches the decoded chunk for SHELL_READY_MARKER and misses markers split
across frames; modify the handler (in session.ts within the Data case) to keep a
small carry buffer on the session (e.g., this._pendingMarkerBuffer) and operate
on Buffers instead of the decoded string: prepend the new payload to the pending
buffer, search that combined Buffer for the SHELL_READY_MARKER sequence, remove
the marker bytes when found (set this.shellReadyResolved and call
this.shellReadyResolve as before), forward the remaining bytes to clients as
UTF-8, and if not found keep only the trailing N-1 bytes of the combined buffer
(N = marker length) to avoid unbounded growth so future frames can complete a
split marker.

In `@apps/desktop/src/main/terminal-host/terminal-host.ts`:
- Around line 153-169: Register the newly spawned Session in this.sessions
before awaiting session.waitForShellReady() (or use a separate pending map) so
concurrent createOrAttach/kill/disconnect can see it; then after the
promiseWithTimeout resolves/rejects re-check session.isAlive (or call a helper)
and if the session already exited (Session.handleSubprocessExit set it dead)
remove it from this.sessions and treat it as non-new. Specifically, move the
this.sessions.set(sessionId, session) to just before calling
promiseWithTimeout(session.waitForShellReady(), SHELL_READY_TIMEOUT_MS) (or add
an inFlight map entry), and after waiting re-evaluate session.isAlive and adjust
isNew/cleanup accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 745a4d51-6739-46f9-8f93-2275f60236b5

📥 Commits

Reviewing files that changed from the base of the PR and between 3c2d245 and 0e459fa.

📒 Files selected for processing (4)
  • apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts
  • apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts
  • apps/desktop/src/main/terminal-host/session.ts
  • apps/desktop/src/main/terminal-host/terminal-host.ts

@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 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/main/terminal-host/session.ts">

<violation number="1" location="apps/desktop/src/main/terminal-host/session.ts:310">
P1: When the shell-ready marker is split across PTY frames, slicing with `this.shellReadyCarry.length` can drop prompt/output bytes and corrupt the OSC sequence handling.</violation>
</file>

<file name="apps/desktop/src/main/terminal-host/terminal-host.ts">

<violation number="1" location="apps/desktop/src/main/terminal-host/terminal-host.ts:178">
P2: The post-wait liveness check uses `isAlive`, which misses sessions that became terminating during initialization. This can return a non-attachable session after a concurrent kill.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/terminal-host/session.ts`:
- Around line 299-329: The carry-buffer logic around SHELL_READY_MARKER is
corrupting output because partial-marker bytes from this.shellReadyCarry are
being forwarded and later assumed already-sent; fix by never forwarding the
carry itself and instead forward only the safe prefix of combined data that
cannot be part of a split marker: when marker not found, set maxCarry =
SHELL_READY_MARKER.length - 1, set this.shellReadyCarry =
combined.slice(-maxCarry) and set data = combined.slice(0, combined.length -
maxCarry) (so potential marker tail is withheld); when marker found, compute
stripped = combined.slice(0, markerIndex) + combined.slice(markerIndex +
SHELL_READY_MARKER.length), set data = stripped (do not slice by
this.shellReadyCarry.length), clear this.shellReadyCarry, set
this.shellReadyResolved and call this.shellReadyResolve as before; keep the
existing early-break if data.length === 0.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 788c460c-80c0-45d5-8d1d-d41408152c67

📥 Commits

Reviewing files that changed from the base of the PR and between 0e459fa and 8c904c1.

📒 Files selected for processing (3)
  • apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts
  • apps/desktop/src/main/terminal-host/session.ts
  • apps/desktop/src/main/terminal-host/terminal-host.ts

@Roshvan
Roshvan force-pushed the fix/terminal-shell-ready-marker branch from 6bd85c7 to 2073451 Compare March 14, 2026 05:42

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
apps/desktop/src/main/terminal-host/session.ts (1)

303-303: Minor: The marker is 27 bytes, not 30.

The comment states "the marker is 30 bytes" but SHELL_READY_MARKER is 27 bytes (\x1b + ]777;superset-shell-ready + \x07).

📝 Suggested fix
-				// still detected (the marker is 30 bytes; a single printf usually
+				// still detected (the marker is 27 bytes; a single printf usually
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/terminal-host/session.ts` at line 303, The inline
comment incorrectly states the marker is 30 bytes; update the comment near
SHELL_READY_MARKER in session.ts to say the marker is 27 bytes (it consists of
'\x1b' + ']777;superset-shell-ready' + '\x07'), so adjust the sentence "the
marker is 30 bytes" to "the marker is 27 bytes" (or reword to compute length
from SHELL_READY_MARKER) to keep the comment accurate.
apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts (1)

270-292: Misleading comment: bash hook is prepended, not appended.

The comment on lines 270-272 states the hook "fires AFTER direnv and other hooks complete," but the implementation prepends _superset_shell_ready to PROMPT_COMMAND (lines 288, 291), causing it to fire BEFORE other hooks in the chain.

This doesn't break functionality—the marker still fires when the first prompt is about to display (after RC files are sourced), which is the key requirement. However, the comment is inaccurate.

📝 Suggested comment fix
-# One-shot shell-ready marker for preset command timing.
-# Uses PROMPT_COMMAND so it fires AFTER direnv and other hooks complete.
-# Supports both scalar and array PROMPT_COMMAND (Bash 5.1+).
+# One-shot shell-ready marker for preset command timing.
+# Uses PROMPT_COMMAND to fire when the first prompt is about to display
+# (after RC files are sourced). Supports both scalar and array PROMPT_COMMAND (Bash 5.1+).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts` around lines 270 -
292, Update the misleading comment above the _superset_shell_ready function to
reflect that the hook is prepended to PROMPT_COMMAND and therefore runs before
(not after) other PROMPT_COMMAND hooks like direnv; specifically mention that we
insert "_superset_shell_ready" at the front of PROMPT_COMMAND (see
PROMPT_COMMAND=("_superset_shell_ready" "${PROMPT_COMMAND[@]}") and the
non-array branch) so the marker fires on the first prompt before other hooks,
and adjust wording to say it ensures the marker fires when the first prompt is
about to display rather than claiming it runs after other hooks complete.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts`:
- Around line 270-292: Update the misleading comment above the
_superset_shell_ready function to reflect that the hook is prepended to
PROMPT_COMMAND and therefore runs before (not after) other PROMPT_COMMAND hooks
like direnv; specifically mention that we insert "_superset_shell_ready" at the
front of PROMPT_COMMAND (see PROMPT_COMMAND=("_superset_shell_ready"
"${PROMPT_COMMAND[@]}") and the non-array branch) so the marker fires on the
first prompt before other hooks, and adjust wording to say it ensures the marker
fires when the first prompt is about to display rather than claiming it runs
after other hooks complete.

In `@apps/desktop/src/main/terminal-host/session.ts`:
- Line 303: The inline comment incorrectly states the marker is 30 bytes; update
the comment near SHELL_READY_MARKER in session.ts to say the marker is 27 bytes
(it consists of '\x1b' + ']777;superset-shell-ready' + '\x07'), so adjust the
sentence "the marker is 30 bytes" to "the marker is 27 bytes" (or reword to
compute length from SHELL_READY_MARKER) to keep the comment accurate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fe86ae77-c335-4ef3-90b6-fac1bcf1c70b

📥 Commits

Reviewing files that changed from the base of the PR and between 8c904c1 and 2073451.

📒 Files selected for processing (4)
  • apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts
  • apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts
  • apps/desktop/src/main/terminal-host/session.ts
  • apps/desktop/src/main/terminal-host/terminal-host.ts

@Kitenite Kitenite left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review agent take:

Findings

  1. apps/desktop/src/main/terminal-host/terminal-host.ts:150
    createOrAttach() is carrying shell-init latency. That fixes preset timing, but it puts the wait on the wrong boundary: terminal creation/attach now blocks on prompt readiness. The terminal should be attachable as soon as the PTY is live; only stdin delivery needs to wait for shell readiness.

  2. apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts:267
    The bash path prepends _superset_shell_ready to PROMPT_COMMAND, so the marker can fire before existing prompt hooks. If direnv/devenv/conda work is in PROMPT_COMMAND, this can still signal readiness too early.

  3. apps/desktop/src/main/terminal-host/session.ts:296
    The marker stripping logic is still chunk-sensitive. A split marker can leak prefix bytes into xterm/output because the current carry approach forwards bytes before it knows whether they belong to the marker.

Correct architecture

  • Keep the shell-emitted marker. That is the right detector for “interactive shell is actually ready for input.” PTY spawn alone cannot tell us that.
  • Move readiness ownership into Session, not TerminalHost.createOrAttach().
  • Attach immediately once the PTY is spawn-ready.
  • Buffer writes in Session while readiness is pending for shells that advertise the marker.
  • Resolve that pending state on marker, timeout, unsupported shell, or subprocess exit, then flush or drop the queued writes in order.
  • Leave the renderer sequencing alone for now. If the host owns the pre-ready input queue, await createOrAttach(); write(...) becomes safe without stalling session attach.
  • Optional follow-up: make preset launch atomic by allowing createOrAttach to carry initial input, but that is a cleanup on top of the host-side queue, not the core fix.

Plan

  1. Introduce session-scoped shell readiness state: pending | ready | timed_out | unsupported | exited plus a small pre-ready stdin queue.
  2. Start the readiness timeout when the session spawns, not inside createOrAttach().
  3. Remove the blocking waitForShellReady() from TerminalHost.createOrAttach(); register the session and attach/snapshot immediately after PTY readiness.
  4. Gate Session.write() on shell readiness for new interactive shells, then flush queued writes in FIFO order when readiness resolves.
  5. Fix bash hook ordering so _superset_shell_ready runs after existing PROMPT_COMMAND hooks instead of before them.
  6. Replace the current carry-based marker stripping with safe-prefix buffering or a small byte-level state machine so split markers never leak into terminal output.
  7. Add focused coverage for:
  • preset write during delayed shell init
  • marker split across PTY frames
  • bash scalar and array PROMPT_COMMAND
  • kill/exit before readiness resolves
  • unsupported shells falling back without blocking attach

I’d reframe the PR around host-side input gating rather than blocking session creation. That keeps the UX responsive and fixes the actual race at the write boundary.

@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.

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/main/terminal-host/session.ts">

<violation number="1" location="apps/desktop/src/main/terminal-host/session.ts:308">
P1: Split-frame shell-ready marker stripping can leak an unterminated OSC prefix to the terminal stream, causing subsequent output to be consumed until a later terminator appears.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/desktop/src/main/terminal-host/session.ts (1)

294-319: ⚠️ Potential issue | 🔴 Critical

Carry-buffer handling still corrupts output when marker spans frames.

Line 317 stores carry but Line 321 can still forward bytes that may be part of the marker tail. Then Line 308 slices only current-frame data assuming carry was already safe, which can leak partial OSC bytes and drop real output after the marker.

🐛 Proposed fix
 				if (!this.shellReadyResolved) {
 					const combined = this.shellReadyCarry + data;
 					const markerIndex = combined.indexOf(SHELL_READY_MARKER);
 					if (markerIndex !== -1) {
-						// Strip only marker bytes belonging to current frame
-						// (carry bytes were already forwarded last time)
-						const carryLen = this.shellReadyCarry.length;
-						const markerEnd = markerIndex + SHELL_READY_MARKER.length;
-						const dataStripStart = Math.max(0, markerIndex - carryLen);
-						const dataStripEnd = markerEnd - carryLen;
-						data = data.slice(0, dataStripStart) + data.slice(dataStripEnd);
+						// Strip marker from full combined stream (carry + current frame).
+						data =
+							combined.slice(0, markerIndex) +
+							combined.slice(markerIndex + SHELL_READY_MARKER.length);
 						this.shellReadyCarry = "";
 						this.shellReadyResolved = true;
 						if (this.shellReadyResolve) {
 							this.shellReadyResolve();
 							this.shellReadyResolve = null;
 						}
 					} else {
 						const maxCarry = SHELL_READY_MARKER.length - 1;
-						this.shellReadyCarry = combined.slice(-maxCarry);
+						// Forward only the safe prefix; keep possible marker tail in carry.
+						const safeLength = Math.max(0, combined.length - maxCarry);
+						data = safeLength > 0 ? combined.slice(0, safeLength) : "";
+						this.shellReadyCarry = combined.slice(safeLength);
 					}
 				}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/terminal-host/session.ts` around lines 294 - 319, The
carry-handling currently can forward bytes that may be part of a marker spanning
frames; update the logic in the session terminal data handler (variables:
shellReadyCarry, shellReadyResolved, shellReadyResolve, SHELL_READY_MARKER,
payload/data) so that while shellReadyResolved is false you never forward the
trailing up-to-(SHELL_READY_MARKER.length-1) bytes of the combined carry+data
(they must be kept in shellReadyCarry), and only forward the safe prefix; when
the marker is found in combined, remove the marker from the combined stream and
forward the safe bytes (excluding the retained carry tail), set
shellReadyResolved and resolve shellReadyResolve; otherwise set shellReadyCarry
to the last maxCarry bytes of combined and do not forward those bytes. Ensure
all slicing uses the combined buffer/string so you don't accidentally forward
bytes that might belong to the marker.
🧹 Nitpick comments (1)
apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts (1)

221-221: Avoid hardcoding the shell-ready OSC marker in multiple snippets.

The same marker literal is embedded in zsh/bash/fish wrappers. Please source it from a single constant (or shared helper) to avoid drift from apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts.

Also applies to: 270-270, 324-324

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts` at line 221, Extract
the OSC marker literal into a single exported constant (e.g., SHELL_READY_OSC or
SHELL_READY_OSC_MARKER) in the module that already defines it (the code in
pty-subprocess-ipc.ts), export that constant, then import and use that constant
in shell-wrappers.ts to replace the hardcoded
'\\033]777;superset-shell-ready\\007' occurrences inside the zsh/bash/fish
wrapper strings (the printf usages in shell-wrappers.ts at the three places
noted) so all snippets reference the shared constant rather than duplicating the
literal.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts`:
- Around line 283-288: The current logic prepends _superset_shell_ready to
PROMPT_COMMAND (both array and scalar branches), causing it to run before
existing hooks; change both branches to append the hook so _superset_shell_ready
runs after existing PROMPT_COMMAND hooks. For the array branch that currently
assigns PROMPT_COMMAND=("_superset_shell_ready" "${PROMPT_COMMAND[@]}"), update
it to append the symbol _superset_shell_ready to the existing array (use
PROMPT_COMMAND+= or equivalent) and for the scalar branch that currently builds
_superset_orig_prompt_cmd and sets
PROMPT_COMMAND="_superset_shell_ready;${_superset_orig_prompt_cmd}", swap the
order so the existing command runs first and then _superset_shell_ready is
appended (preserve any quoting/escaping used for _superset_orig_prompt_cmd).

---

Duplicate comments:
In `@apps/desktop/src/main/terminal-host/session.ts`:
- Around line 294-319: The carry-handling currently can forward bytes that may
be part of a marker spanning frames; update the logic in the session terminal
data handler (variables: shellReadyCarry, shellReadyResolved, shellReadyResolve,
SHELL_READY_MARKER, payload/data) so that while shellReadyResolved is false you
never forward the trailing up-to-(SHELL_READY_MARKER.length-1) bytes of the
combined carry+data (they must be kept in shellReadyCarry), and only forward the
safe prefix; when the marker is found in combined, remove the marker from the
combined stream and forward the safe bytes (excluding the retained carry tail),
set shellReadyResolved and resolve shellReadyResolve; otherwise set
shellReadyCarry to the last maxCarry bytes of combined and do not forward those
bytes. Ensure all slicing uses the combined buffer/string so you don't
accidentally forward bytes that might belong to the marker.

---

Nitpick comments:
In `@apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts`:
- Line 221: Extract the OSC marker literal into a single exported constant
(e.g., SHELL_READY_OSC or SHELL_READY_OSC_MARKER) in the module that already
defines it (the code in pty-subprocess-ipc.ts), export that constant, then
import and use that constant in shell-wrappers.ts to replace the hardcoded
'\\033]777;superset-shell-ready\\007' occurrences inside the zsh/bash/fish
wrapper strings (the printf usages in shell-wrappers.ts at the three places
noted) so all snippets reference the shared constant rather than duplicating the
literal.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 37a49fed-5b9e-4637-a8c9-be2d68bbabc1

📥 Commits

Reviewing files that changed from the base of the PR and between 2073451 and c0e5e51.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts
  • apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts
  • apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts
  • apps/desktop/src/main/terminal-host/session-lifecycle.test.ts
  • apps/desktop/src/main/terminal-host/session.ts
  • apps/desktop/src/main/terminal-host/terminal-host.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts

@Roshvan
Roshvan force-pushed the fix/terminal-shell-ready-marker branch from c0e5e51 to cf92810 Compare March 15, 2026 02:43
@Roshvan Roshvan changed the title fix(desktop): wait for shell initialization before sending preset commands fix(desktop): gate terminal stdin on shell readiness to prevent preset command loss Mar 15, 2026
@Roshvan
Roshvan force-pushed the fix/terminal-shell-ready-marker branch from cf92810 to 5adab42 Compare March 15, 2026 02:49

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (2)
apps/desktop/src/main/terminal-host/session-shell-ready.test.ts (2)

178-185: Remove unused _broadcastedData variable.

The _broadcastedData variable is declared and reset in createTrackedSession but never actually used in any test assertions. This appears to be leftover scaffolding.

Simplify by removing unused tracking
 describe("Session shell-ready: marker detection", () => {
-	let _broadcastedData: string[];
-
-	function createTrackedSession(shell: string) {
-		const result = createTestSession(shell);
-		_broadcastedData = [];
-		return result;
-	}
+	// Use createTestSession directly - no special tracking needed
 
 	it("strips marker from single data frame", () => {
-		const { session, proc } = createTrackedSession("/bin/zsh");
+		const { session, proc } = createTestSession("/bin/zsh");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/terminal-host/session-shell-ready.test.ts` around lines
178 - 185, Remove the unused tracking variable by deleting the declaration and
resets for _broadcastedData and any related references in the test; specifically
remove the top-level let _broadcastedData: string[]; and the line that sets
_broadcastedData = []; inside createTrackedSession (function name
createTrackedSession and variable _broadcastedData identify the locations to
change), leaving createTrackedSession to simply return createTestSession(shell)
and updating any tests that referenced _broadcastedData to use actual assertions
or remove those unused assertions.

161-175: Test title is slightly misleading.

The test is titled "flushes buffered writes on timeout" but actually tests exit behavior. While the result is equivalent (both resolve shell readiness and flush the queue), the title suggests timer-based timeout testing.

Consider renaming to "flushes buffered writes on subprocess exit" for clarity, or add a separate test using timer mocks to test the actual 15s timeout path.

Suggested rename
-	it("flushes buffered writes on timeout", async () => {
+	it("flushes buffered writes on subprocess exit", async () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/terminal-host/session-shell-ready.test.ts` around lines
161 - 175, Rename the test title to accurately reflect that it exercises
subprocess exit behavior (e.g., change the it description in
session-shell-ready.test.ts from "flushes buffered writes on timeout" to
"flushes buffered writes on subprocess exit") so it's clear this uses
sendExit/proc.emit to resolve readiness; alternatively add a new test that uses
Jest's fake timers to simulate the actual 15s timeout path and verify buffered
writes are flushed there (use createTestSession, spawnAndReady, session.write,
getWrittenData and the timeout resolution path).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/desktop/src/main/terminal-host/session-shell-ready.test.ts`:
- Around line 178-185: Remove the unused tracking variable by deleting the
declaration and resets for _broadcastedData and any related references in the
test; specifically remove the top-level let _broadcastedData: string[]; and the
line that sets _broadcastedData = []; inside createTrackedSession (function name
createTrackedSession and variable _broadcastedData identify the locations to
change), leaving createTrackedSession to simply return createTestSession(shell)
and updating any tests that referenced _broadcastedData to use actual assertions
or remove those unused assertions.
- Around line 161-175: Rename the test title to accurately reflect that it
exercises subprocess exit behavior (e.g., change the it description in
session-shell-ready.test.ts from "flushes buffered writes on timeout" to
"flushes buffered writes on subprocess exit") so it's clear this uses
sendExit/proc.emit to resolve readiness; alternatively add a new test that uses
Jest's fake timers to simulate the actual 15s timeout path and verify buffered
writes are flushed there (use createTestSession, spawnAndReady, session.write,
getWrittenData and the timeout resolution path).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b2ae59c-46f4-4996-9d21-163de8ed316a

📥 Commits

Reviewing files that changed from the base of the PR and between c0e5e51 and cf92810.

📒 Files selected for processing (4)
  • apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts
  • apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts
  • apps/desktop/src/main/terminal-host/session-shell-ready.test.ts
  • apps/desktop/src/main/terminal-host/session.ts

@Roshvan
Roshvan force-pushed the fix/terminal-shell-ready-marker branch 4 times, most recently from 71bc3aa to 66f4c49 Compare March 15, 2026 03:34
…t command loss

When a preset launches a terminal and sends a command (e.g. `claude`),
the command was lost because the shell hadn't finished initializing.
Tools like direnv, devenv, nvm, conda, and oh-my-zsh hook into shell
startup and take non-trivial time to load. The preset command arrived
during that initialization window and was swallowed.

Solution: Session-owned shell readiness state machine that gates
`write()` on shell initialization status. A one-shot OSC marker
(\033]777;superset-shell-ready\007) is emitted from shell prompt hooks
once the shell is fully initialized:

  - zsh: precmd hook in .zlogin (last in precmd_functions)
  - bash: PROMPT_COMMAND hook (appended, supports Bash 5.1+ arrays)
  - fish: --on-event fish_prompt handler

Session detects the marker via a byte-level state machine that
withholds potential marker bytes from terminal output until confirmed,
preventing any leakage to xterm even across PTY data frame boundaries.

During shell init:
  - User commands are buffered in a pre-ready stdin queue
  - Terminal protocol responses (DA, DSR) are dropped — they are stale
    responses that would appear as typed text (e.g. ?62;4;9;22c)
  - Headless emulator responses are dropped (renderer handles queries
    once a client attaches)
  - All buffered writes flush in FIFO order once readiness resolves

createOrAttach() returns immediately — the terminal attaches and
renders right away. Only stdin delivery is gated, not session creation.

This follows the same pattern used by VS Code (OSC 133), iTerm2 (OSC
1337), and Warp (OSC 9001) for shell integration.
@Roshvan
Roshvan force-pushed the fix/terminal-shell-ready-marker branch from 66f4c49 to 2096641 Compare March 15, 2026 18:33
@Roshvan

Roshvan commented Mar 15, 2026

Copy link
Copy Markdown
Contributor Author

@Kitenite — Addressed all your feedback. Here's a walkthrough of the implementation and how it maps to your plan.

The Problem

When you open a terminal in Superset and a preset tries to run a command (like claude), the command gets lost. The shell (zsh/bash/fish) isn't ready yet — it's still loading things like direnv, nvm, oh-my-zsh, conda, etc. The command arrives while the shell is busy initializing and gets swallowed.

What Was Wrong With the First Approach

The initial fix blocked createOrAttach() — the function that creates a terminal session and connects the UI to it. It waited up to 15 seconds for the shell to be ready before returning. This meant:

  • The terminal wouldn't render at all until the shell finished loading
  • The user couldn't see direnv output, loading messages, etc.
  • It felt like the app was frozen

Your review correctly identified this: the terminal should render immediately. Only the command delivery needs to wait.

How It Works Now

1. Shell-side: One-shot ready marker

Our shell wrappers now inject a tiny invisible signal — an OSC escape sequence — that fires right before the first prompt appears:

\033]777;superset-shell-ready\007

Same pattern VS Code (OSC 133), iTerm2 (OSC 1337), and Warp (OSC 9001) use for shell integration. We use 777 to avoid conflicts.

Why a prompt hook? Because precmd (zsh) / PROMPT_COMMAND (bash) / fish_prompt (fish) fire at exactly the right moment: after ALL initialization is done (direnv, nvm, conda, everything), right before the shell shows $ and is ready for input.

Why last in the hook chain? direnv and other tools also register prompt hooks. We append ours at the end so it fires AFTER everything else finishes. If we prepended it, the marker would fire before direnv was done loading.

2. Session-side: State machine + write gating

Instead of blocking session creation, the Session class now owns a readiness state machine:

pending → ready       (marker detected in PTY output)
pending → timed_out   (15s elapsed, no marker — degrade gracefully)
unsupported           (sh/ksh — no marker injected, writes pass through immediately)

How write() works now:

  • Shell ready? → send directly to PTY stdin
  • Shell pending + user command? → buffer it in a queue
  • Shell pending + escape sequence (\x1b...)? → drop it (stale terminal response)

When the marker arrives (or the timeout fires), all buffered commands flush to the PTY in order.

Why drop escape sequences? During shell init, zsh queries terminal capabilities. The renderer's xterm responds with something like \x1b[?62;4;9;22c. If we buffered and flushed that alongside the preset command, the shell would interpret those bytes as typed text — that's where the ?62;4;9;22c garbage was coming from.

3. Marker detection: Byte-level state machine

We scan PTY output one character at a time, matching against the marker. Bytes that might be part of the marker are held back. Full match → discard held bytes and resolve readiness. Mismatch → flush held bytes as regular output. Zero marker bytes ever leak to xterm, even across chunk boundaries.

4. Headless emulator DA responses

The headless emulator responds to terminal queries when no renderer client is attached. During init we drop these — they'd pollute the stdin queue and appear as typed text once flushed. After a client attaches the renderer handles all queries.

Mapping to Your Plan

Your plan item Implementation
1. Session-scoped readiness state + stdin queue ShellReadyState type (pending | ready | timed_out | unsupported) + preReadyStdinQueue
2. Start timeout in spawn(), not createOrAttach() Timeout starts at end of spawn() method
3. Remove blocking from createOrAttach() terminal-host.ts has zero changes — completely unchanged from main
4. Gate Session.write(), flush FIFO write() checks state, buffers or drops; resolveShellReady() flushes queue
5. Fix bash hook ordering Appended (not prepended), handles scalar and Bash 5.1+ array PROMPT_COMMAND
6. Replace carry buffer with state machine Character-by-character scanner with held bytes — no leakage across chunks
7. Focused test coverage 19 unit tests: write buffering, marker split, DA dropping, kill/exit, shell matrix

Files Changed

  • pty-subprocess-ipc.ts — SHELL_READY_MARKER constant
  • shell-wrappers.ts — marker hooks for zsh/bash/fish
  • session.ts — state machine, write gating, marker scanner
  • session-shell-ready.test.ts — 19 tests
  • terminal-host.ts — no changes

User Experience

Before: Terminal opens, hangs for up to 15s (or command lost entirely).
After: Terminal opens instantly, direnv output visible in real time, preset command runs cleanly after prompt appears.

Happy to adjust further — let me know what you think or if there's another way you'd like to tackle this. Happy to pivot.

@Roshvan

Roshvan commented Mar 19, 2026

Copy link
Copy Markdown
Contributor Author

@kiet this also blocks anyone trying to do Cmd+N or new workspace from firing off with Claude, since it will try to spin up and break.

@Roshvan
Roshvan requested a review from Kitenite March 19, 2026 12:46
@Kitenite
Kitenite merged commit 2d1885a into ParadevAI:main Mar 19, 2026
7 checks passed
@Roshvan
Roshvan deleted the fix/terminal-shell-ready-marker branch October 11, 2026 14:50
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.

2 participants