Skip to content

fix: carry PTY output as bytes end-to-end (renderer + daemon wires)#3981

Merged
Kitenite merged 7 commits into
mainfrom
faceted-minnow
May 2, 2026
Merged

fix: carry PTY output as bytes end-to-end (renderer + daemon wires)#3981
Kitenite merged 7 commits into
mainfrom
faceted-minnow

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented May 2, 2026

Summary

Two architectural smells, same shape, both fixed:

  1. Host-service ↔ renderer wire had chunk.toString(\"utf8\") per chunk on PTY output with no StringDecoder, mangling multi-byte codepoints at chunk boundaries. (TUIs with box-drawing/powerline glyphs/emoji glitched under load. Pure-ASCII output was unaffected, which is why nothing screamed during PR feat: pty-daemon integration — terminal sessions survive host-service restarts #3896 smoke.)
  2. Daemon ↔ host-service wire base64'd PTY bytes into a JSON data field — wire-correct, but a 33% bandwidth tax + an encode/decode pass per chunk in each direction, paid for nothing.

After this PR, PTY bytes travel kernel → daemon → wire → host-service → wire → xterm.js with zero decoding hops. Control messages stay JSON.

┌─ daemon ─────────────────────────────────────────────────────┐
│ node-pty (encoding: null) → Buffer                           │
└────────────────────────┬─────────────────────────────────────┘
                         ▼ Unix socket, [u32 totalLen][u32 jsonLen][json][bytes]
┌─ host-service ──────────────────────────────────────────────┐
│ DaemonClient → Buffer  → byte-form OSC scanners            │
│              → broadcast as binary WebSocket frame          │
└────────────────────────┬─────────────────────────────────────┘
                         ▼ binary WS frame
┌─ renderer ──────────────────────────────────────────────────┐
│ binaryType=\"arraybuffer\" → terminal.write(new Uint8Array(…))│
└─────────────────────────────────────────────────────────────┘

Commits, ordered for review

  1. fix(host-service): carry PTY output as bytes end-to-end — host-service stops calling chunk.toString(\"utf8\") per chunk; bytes flow daemon → ws → xterm via binary WS frames. OSC scanners ride alongside (additive at this point).
  2. refactor(shared): collapse OSC scanners onto a single byte-oriented impl — rip the string-form scanners now that nothing depends on them. v1's session.ts incidentally gets fixed for free. Net 354 deleted, 184 added.
  3. fix(desktop,shared): address review feedback on byte-scanner refactor — addresses CodeRabbit (v1's resolveShellReady was still treating heldBytes as string after the type change) + cubic (subarray() retains the original chunk's ArrayBuffer; copy the bytes before persisting in scanner state).
  4. feat(pty-daemon): carry PTY bytes as binary frame tail (protocol v2) — daemon↔host wire goes from [u32 len][JSON-with-base64] to [u32 totalLen][u32 jsonLen][JSON][optional payload bytes]. OutputMessage/InputMessage drop their data field; bytes ride in the binary tail.

Protocol v2 — no backward compat

Daemon protocol bumps from v1 to v2. New host-service refuses to talk to old daemon (hello-mismatch → connection rejected → supervisor force-restarts the daemon → new bundle takes over). Phase 2 (PR #3971) is what makes this acceptable: auto-update converges daemon to current on host-service start, so the version-skew window is bounded to the one upgrade in which this lands. Sessions die during that window. After: every subsequent upgrade preserves sessions via Phase 2's fd-handoff.

This was a deliberate choice over maintaining a v1 fallback. Two implementations forever was the wrong tradeoff for a one-time migration cost.

What's intentionally untouched

  • Input direction (renderer → daemon). xterm's onData emits semantically complete UTF-8 strings (one keystroke/paste = one full sequence), so the per-chunk mangling that motivated this rewrite doesn't apply to input. We do still ride binary tail on the daemon↔host hop for input bytes — that's the v2 protocol — but the renderer→host hop stays JSON.

Tests

  • bun test: 685/685 across pty-daemon, host-service, shared
  • node --test packages/pty-daemon/test/control-plane.test.ts: 31/31 through real Unix sockets — late subscribers, host-service restart adoption, hostile input, 20 concurrent sessions, partial-frame TCP chunking
  • node --test packages/pty-daemon/test/integration.test.ts: 3/3 daemon-binary smoke tests
  • Workspace tsc: clean across pty-daemon, host-service, shared, apps/desktop

Test plan

  • CI green (lint, typecheck, test)
  • Manual QA: bun dev, open v2 terminal, run something Unicode-heavy under load — lazygit, vim w/ powerline, btop, or:
    printf '🙂%.0s' {1..10000}
    seq 1 1000 | xargs printf \"%s\\xe4\\xbd\\xa0\\xe5\\xa5\\xbd\\n\"
    
    Pre-PR symptom: U+FFFD smattered across the buffer + broken box-drawing under load. Post-PR: clean.
  • Regression smoke: open + close terminals, host-service restart with adopted sessions, daemon binary upgrade (Phase 2) — sessions preserved.

Out of scope

  • v1 desktop main-process terminal-host has the same mangling regression at its remaining payload.toString(\"utf8\") site (after the scanner). Commit 2 fixes the scanner side as a side effect; the post-strip emulator/IPC string conversion still has chunk-boundary issues. v1 is sunset; we don't fix deeper.
  • Cross-platform validation. Verified macOS arm64. Linux/x86_64 manual smoke is on the test plan.

Summary by CodeRabbit

  • New Features

    • Terminal output now streams as raw binary frames end-to-end (protocol v2) for exact replay and lower-latency delivery.
    • WebSocket transport accepts binary PTY frames directly for playback.
  • Bug Fixes

    • Byte-oriented scanners prevent UTF‑8 corruption across chunk boundaries and reliably detect/strip shell‑ready markers and OSC titles, flushing partial bytes.
    • More consistent multi‑client replay/fan‑out with fewer dropped or mangled bytes.
  • Tests

    • Added extensive byte‑level, framing, and integration tests to ensure byte‑perfect behavior.

Kitenite added 2 commits May 2, 2026 09:56
PR #3896 routed v2 terminal output through the daemon, which emits raw
Buffers on the wire (correct) — but the host-service receive path then
called `chunk.toString("utf8")` per chunk before forwarding to the WS,
with no StringDecoder buffering partial codepoints. Any UTF-8 sequence
that straddled a chunk boundary lost its trailing bytes to U+FFFD on
this side and its leading bytes to U+FFFD on the next, mangling
TUIs that lean on box-drawing/powerline glyphs/emoji under load.

Bytes now travel daemon → host-service → WebSocket → xterm.js with
no decoding hop:
- byte-oriented variants of the OSC 133 and terminal-title scanners
  (framing is pure ASCII; only the title payload itself is decoded,
  and only at a terminator-bounded slice where the decode is lossless)
- replay buffer + WS broadcast use Uint8Array; data/replay travel as
  binary WS frames; control messages stay JSON
- portManager.checkOutputForHint gets a per-session StringDecoder
  side-channel so its URL/port regexes still see strings without
  forcing the data path through utf-8
- DaemonPty.onData (the legacy adapter teardown.ts uses) gets its own
  StringDecoder so its tail-output buffer for db storage is also
  free of the per-chunk mangling
The previous commit added byte variants alongside the existing string
ones. With the v2 path migrated, the only remaining string-variant
consumer was v1's session.ts — leaving two parallel scanner
implementations indefinitely. Rip the string variants:

- scanForShellReady, scanForTerminalTitle: Uint8Array in/out (was string).
  Drop *Bytes-suffixed names; the byte form is the form.
- v1's session.ts updated to feed bytes directly to scanForShellReady;
  the IPC handler already had the Buffer payload, so the change is
  one less `payload.toString("utf8")` rather than a structural one.
- Tests rewritten against the byte API. New `bin()` Latin-1 helper for
  fixtures with raw C1 control bytes (0x9D / 0x9C) so they map to single
  bytes the way real PTYs send them, not to TextEncoder's 2-byte UTF-8
  forms.

Net: 354 lines removed, 184 added. One scanner instead of two.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 2, 2026

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

Terminal I/O shifted from JSON UTF‑8 text chunks to binary PTY byte frames end‑to‑end. OSC 133 (shell‑ready) and OSC title scanners were rewritten to operate on Uint8Array bytes. Framing, protocol (v2), daemon, server, host-service, desktop session, renderer, and tests updated for binary payload tails.

Changes

Byte‑oriented terminal pipeline

Layer / File(s) Summary
Data Shape / Constants
packages/shared/src/shell-ready-scanner.ts, packages/shared/src/terminal-title-scanner.ts
Scanner state fields moved from string-based containers to byte-based ones (heldBytes: number[], buffer: Uint8Array); OSC constants converted to byte arrays/numeric terminators.
Core Scanners
packages/shared/src/shell-ready-scanner.ts, packages/shared/src/terminal-title-scanner.ts
scanForShellReady and scanForTerminalTitle rewritten to accept Uint8Array, perform byte-wise matching across chunks, strip OSC sequences at byte level, and return/copy byte outputs.
Framing / Protocol
packages/pty-daemon/src/protocol/framing.ts, packages/pty-daemon/src/protocol/messages.ts, packages/pty-daemon/src/protocol/version.ts
Framing advanced to v2 [u32 totalLen][u32 jsonLen][JSON][optional payload bytes]; encodeFrame/decodeFrame/FrameDecoder.drain() handle optional binary payload tails and return { message, payload }; InputMessage/OutputMessage drop base64 data fields; protocol version bumped to 2.
Daemon / Server
packages/pty-daemon/src/Server/Server.ts, packages/pty-daemon/src/handlers/handlers.ts, packages/pty-daemon/src/handlers/handlers.test.ts
Conn/dispatch/send APIs extended to accept optional Uint8Array payload; handleInput writes raw payload bytes to PTY; output/replay send payload tails instead of base64-in-JSON; tests adapted to new frame shape.
Host Service / Client
packages/host-service/src/terminal/terminal.ts, packages/host-service/src/terminal/DaemonClient/DaemonClient.ts
Terminal pipeline changed to byte buffers (Uint8Array[]), added byte buffering/broadcast helpers; daemon client sends/receives frame payloads and uses StringDecoder for side-channel text decoding.
Desktop Session
apps/desktop/src/main/terminal-host/session.ts
PTY Data handling now scans raw Buffer bytes with scanForShellReady before UTF‑8 decoding; resolveShellReady flushes held marker bytes by decoding held bytes as UTF‑8 prior to enqueue/broadcast.
Renderer / WS Transport
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
WebSocket sets binaryType = "arraybuffer"; binary ArrayBuffer messages are written directly to xterm as Uint8Array; JSON retained for control messages (title, exit, error); data/replay text variants removed.
Tests / Tooling
packages/pty-daemon/test/*, packages/pty-daemon/src/protocol/framing.test.ts, packages/pty-daemon/src/protocol/wire-shape.test.ts, packages/shared/src/*.{test,ts}
Extensive test updates and additions: framing v2 tests, byte-fidelity and no-encoding-hops suites, scanner tests driven by Uint8Array, test helpers expose payloadOf/payloadAsString/accumulatedOutputAsString, and many integration tests switched to raw Buffer payloads and protocol v2 handshake.
sequenceDiagram
    participant PTY as PTY (process)
    participant Session as Desktop Session
    participant Scanner as Byte Scanners
    participant Daemon as Daemon / Server
    participant WS as WebSocket Transport
    participant Renderer as Renderer / xterm

    PTY->>Session: emit bytes (Buffer / Uint8Array)
    Session->>Scanner: scanForShellReady(Uint8Array)
    Scanner-->>Session: { output: Uint8Array, matched: bool }
    Session->>Daemon: broadcast bytes (frame payload) or bufferOutput(bytes)
    Daemon->>Scanner: scanForTerminalTitle(Uint8Array)
    Scanner-->>Daemon: stripped/title bytes
    Daemon->>WS: send JSON control + binary payload
    WS->>Renderer: ArrayBuffer payload
    Renderer->>Renderer: terminal.write(new Uint8Array(payload))
    Renderer->>WS: handle JSON control messages
Loading

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 I nibble bytes in tidy rows,
Strip the markers, watch them go.
Chunks stitch softly, no more tears,
Raw frames hop through all the tiers.
A joyful hop — terminal glows.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.78% 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 specifically summarizes the main architectural change: carrying PTY output as raw bytes end-to-end across the renderer and daemon wire protocols, addressing the core issue of the PR.
Description check ✅ Passed The description is comprehensive and well-structured, covering the architectural issues, the solution design, protocol changes, testing, and known limitations. It follows the template structure with clear sections for context, commits, protocol details, and test plans.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch faceted-minnow

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.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 2, 2026

Greptile Summary

This PR eliminates a UTF-8 boundary-mangling bug introduced in #3896 by carrying PTY output as raw bytes (Uint8Array) all the way from the daemon through host-service to the renderer's xterm.write(), with zero decoding hops in the relay. The OSC scanners (shell-ready, terminal-title) are rewritten to operate on bytes directly — which is correct since all framing characters are pure ASCII — and string decoding is pushed to the only two places that genuinely need it: the title payload (decoded once at normalizeTerminalTitle) and the port-hint side-channel (buffered correctly with a per-session StringDecoder).

Confidence Score: 4/5

Safe to merge — the core fix is correct and well-tested; only P2 style/memory notes remain.

No P0 or P1 findings. Two P2s: a double-copy in the scanForShellReady no-match path, and subarray pinning an incoming chunk's ArrayBuffer for one trailing ESC byte. Neither affects correctness. Tests are comprehensive and cover the exact regression scenario.

packages/shared/src/shell-ready-scanner.ts and packages/shared/src/terminal-title-scanner.ts — both have minor P2 efficiency notes around byte accumulation and subarray aliasing.

Important Files Changed

Filename Overview
packages/shared/src/shell-ready-scanner.ts String-based scanner replaced with byte-oriented Uint8Array implementation; logic is correct but the number[] accumulator causes two full copies of passthrough data on the no-match path (P2 perf).
packages/shared/src/terminal-title-scanner.ts Converted to byte-oriented scanner; sharedTitleTextDecoder singleton is safe for non-streaming use. Trailing-ESC save uses subarray, which can pin a large incoming chunk's ArrayBuffer (P2 memory).
packages/host-service/src/terminal/terminal.ts Replay buffer migrated from string[] to Uint8Array[]; broadcast helper + binary-frame send path are correct; per-session StringDecoder side-channels for port-hint and legacy-teardown consumers are properly isolated from the data path.
apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts binaryType = arraybuffer + binary-frame early-return correctly pipes PTY bytes straight into xterm.write(new Uint8Array(event.data)); data/replay JSON variants cleanly removed.
apps/desktop/src/main/terminal-host/session.ts v1 session updated to pass Buffer directly into the byte scanner, then reconstructs a UTF-8 string via Buffer.from(bytes.buffer, byteOffset, byteLength).toString(utf8) — correct and well-commented as a known-sunset path.
packages/shared/src/shell-ready-scanner.test.ts New test file covering marker spanning two chunks, mid-codepoint split, and flush-of-false-start — good regression coverage for the byte path.
packages/shared/src/terminal-title-scanner.test.ts All string fixtures migrated to enc.encode / bin() byte variants; new regression test for smiley split mid-codepoint at chunk boundary passes correctly.

Sequence Diagram

sequenceDiagram
    participant D as daemon (node-pty)
    participant HS as host-service
    participant WS as WebSocket
    participant R as renderer (xterm.js)

    D->>HS: Buffer (raw bytes, base64-in-JSON)
    Note over HS: scanForTerminalTitle(chunk: Uint8Array)<br/>scanForShellReady(chunk: Uint8Array)<br/>portHintDecoder.write() [side-channel only]
    HS->>WS: binary frame (Uint8Array — no UTF-8 decode)
    Note over WS: binaryType = arraybuffer
    WS->>R: ArrayBuffer
    R->>R: terminal.write(new Uint8Array(event.data))<br/>[xterm.js buffers partial codepoints internally]
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/shared/src/shell-ready-scanner.ts:56-93
**`number[]` accumulation copies every byte twice on the no-match path**

`scanForShellReady` pushes every pass-through byte into `out: number[]` one-at-a-time, then materializes it as `Uint8Array.from(out)` — a second full copy. For a large chunk with no OSC marker, every byte is touched twice in JS userland instead of being handed off as a subarray slice.

Because the scanner only runs while `shellReadyState === "pending"` (and stops after the first match), this rarely affects hot paths. But replacing the `out` accumulator with a mutable "output start" cursor would let the common all-passthrough case return `data.subarray(outStart)` with zero copies. For now the `number[]``Uint8Array.from` round-trip is correct; the suggestion is purely a performance improvement if this ever needs to handle high-throughput pre-ready output.

### Issue 2 of 2
packages/shared/src/terminal-title-scanner.ts:145-148
**`subarray` stores a view into the incoming chunk's `ArrayBuffer`**

When `state.buffer.length === 0`, `input` is set directly to `chunk` (the `concatBytes` identity shortcut). The trailing-ESC save path then does `input.subarray(input.length - 1)` — a zero-copy view that holds a reference to `chunk`'s underlying `ArrayBuffer`. If that `ArrayBuffer` is large (e.g. a 64 KB daemon frame), the entire allocation stays alive until the next call to `scanForTerminalTitle` replaces `state.buffer`.

Switching to `input.slice(input.length - 1)` (which copies the single byte into a fresh 1-byte `ArrayBuffer`) avoids the live reference at the cost of one trivial allocation.

```suggestion
			state.buffer =
				input.length > 0 && input[input.length - 1] === ESC_BYTE
					? input.slice(input.length - 1)
					: new Uint8Array(0);
```

Reviews (1): Last reviewed commit: "refactor(shared): collapse OSC scanners ..." | Re-trigger Greptile

Comment on lines +56 to +93
const out: number[] = [];

for (let i = 0; i < data.length; i++) {
const ch = data[i] as string;
if (state.matchPos < OSC_133_A.length) {
// Still matching the "\x1b]133;A" prefix
if (ch === OSC_133_A[state.matchPos]) {
state.heldBytes += ch;
const b = data[i] as number;
if (state.matchPos < OSC_133_A_BYTES.length) {
if (b === OSC_133_A_BYTES[state.matchPos]) {
state.heldBytes.push(b);
state.matchPos++;
} else {
// Mismatch — flush held bytes, then re-test current char as a
// fresh match start (e.g. stale ESC followed by real marker).
output += state.heldBytes;
state.heldBytes = "";
for (const h of state.heldBytes) out.push(h);
state.heldBytes.length = 0;
state.matchPos = 0;
if (ch === OSC_133_A[0]) {
state.heldBytes = ch;
if (b === OSC_133_A_BYTES[0]) {
state.heldBytes.push(b);
state.matchPos = 1;
} else {
output += ch;
out.push(b);
}
}
} else {
// Matched prefix — consume optional params until string terminator
if (ch === "\x07") {
// Full match — discard held bytes
const remaining = data.slice(i + 1);
state.heldBytes = "";
if (b === BEL_BYTE) {
state.heldBytes.length = 0;
state.matchPos = 0;
return { output: output + remaining, matched: true };
const remaining = data.subarray(i + 1);
const head = Uint8Array.from(out);
if (remaining.length === 0) {
return { output: head, matched: true };
}
const merged = new Uint8Array(head.length + remaining.length);
merged.set(head, 0);
merged.set(remaining, head.length);
return { output: merged, matched: true };
}
// Consume optional params (e.g. ";cl=m;aid=123") before \a
state.heldBytes += ch;
state.heldBytes.push(b);
}
}

return { output, matched: false };
return { output: Uint8Array.from(out), matched: false };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 number[] accumulation copies every byte twice on the no-match path

scanForShellReady pushes every pass-through byte into out: number[] one-at-a-time, then materializes it as Uint8Array.from(out) — a second full copy. For a large chunk with no OSC marker, every byte is touched twice in JS userland instead of being handed off as a subarray slice.

Because the scanner only runs while shellReadyState === "pending" (and stops after the first match), this rarely affects hot paths. But replacing the out accumulator with a mutable "output start" cursor would let the common all-passthrough case return data.subarray(outStart) with zero copies. For now the number[]Uint8Array.from round-trip is correct; the suggestion is purely a performance improvement if this ever needs to handle high-throughput pre-ready output.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/shared/src/shell-ready-scanner.ts
Line: 56-93

Comment:
**`number[]` accumulation copies every byte twice on the no-match path**

`scanForShellReady` pushes every pass-through byte into `out: number[]` one-at-a-time, then materializes it as `Uint8Array.from(out)` — a second full copy. For a large chunk with no OSC marker, every byte is touched twice in JS userland instead of being handed off as a subarray slice.

Because the scanner only runs while `shellReadyState === "pending"` (and stops after the first match), this rarely affects hot paths. But replacing the `out` accumulator with a mutable "output start" cursor would let the common all-passthrough case return `data.subarray(outStart)` with zero copies. For now the `number[]``Uint8Array.from` round-trip is correct; the suggestion is purely a performance improvement if this ever needs to handle high-throughput pre-ready output.

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

Comment on lines +145 to +148
state.buffer =
input.length > 0 && input[input.length - 1] === ESC_BYTE
? input.subarray(input.length - 1)
: new Uint8Array(0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 subarray stores a view into the incoming chunk's ArrayBuffer

When state.buffer.length === 0, input is set directly to chunk (the concatBytes identity shortcut). The trailing-ESC save path then does input.subarray(input.length - 1) — a zero-copy view that holds a reference to chunk's underlying ArrayBuffer. If that ArrayBuffer is large (e.g. a 64 KB daemon frame), the entire allocation stays alive until the next call to scanForTerminalTitle replaces state.buffer.

Switching to input.slice(input.length - 1) (which copies the single byte into a fresh 1-byte ArrayBuffer) avoids the live reference at the cost of one trivial allocation.

Suggested change
state.buffer =
input.length > 0 && input[input.length - 1] === ESC_BYTE
? input.subarray(input.length - 1)
: new Uint8Array(0);
state.buffer =
input.length > 0 && input[input.length - 1] === ESC_BYTE
? input.slice(input.length - 1)
: new Uint8Array(0);
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/shared/src/terminal-title-scanner.ts
Line: 145-148

Comment:
**`subarray` stores a view into the incoming chunk's `ArrayBuffer`**

When `state.buffer.length === 0`, `input` is set directly to `chunk` (the `concatBytes` identity shortcut). The trailing-ESC save path then does `input.subarray(input.length - 1)` — a zero-copy view that holds a reference to `chunk`'s underlying `ArrayBuffer`. If that `ArrayBuffer` is large (e.g. a 64 KB daemon frame), the entire allocation stays alive until the next call to `scanForTerminalTitle` replaces `state.buffer`.

Switching to `input.slice(input.length - 1)` (which copies the single byte into a fresh 1-byte `ArrayBuffer`) avoids the live reference at the cost of one trivial allocation.

```suggestion
			state.buffer =
				input.length > 0 && input[input.length - 1] === ESC_BYTE
					? input.slice(input.length - 1)
					: new Uint8Array(0);
```

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/main/terminal-host/session.ts (1)

1055-1063: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Type mismatch: heldBytes is number[], not string.

After the scanner refactor, ShellReadyScanState.heldBytes is number[] (see packages/shared/src/shell-ready-scanner.ts:29), but this code treats it as a string:

  • enqueueEmulatorWrite(this.scanState.heldBytes) expects string
  • data: this.scanState.heldBytes in TerminalDataEvent expects string
  • this.scanState.heldBytes = "" assigns a string instead of resetting the array
🐛 Proposed fix
 	private resolveShellReady(state: "ready" | "timed_out"): void {
 		if (this.shellReadyState !== "pending") return;
 		this.shellReadyState = state;
 		if (this.shellReadyTimeoutId) {
 			clearTimeout(this.shellReadyTimeoutId);
 			this.shellReadyTimeoutId = null;
 		}
 		// Flush held marker bytes — they weren't part of a full marker
 		if (this.scanState.heldBytes.length > 0) {
-			this.enqueueEmulatorWrite(this.scanState.heldBytes);
+			const flushed = Buffer.from(this.scanState.heldBytes).toString("utf8");
+			this.enqueueEmulatorWrite(flushed);
 			this.broadcastEvent("data", {
 				type: "data",
-				data: this.scanState.heldBytes,
+				data: flushed,
 			} satisfies TerminalDataEvent);
-			this.scanState.heldBytes = "";
+			this.scanState.heldBytes.length = 0;
 		}
 		this.scanState.matchPos = 0;
 	}
🤖 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 1055 - 1063,
scanState.heldBytes is now a number[] (per ShellReadyScanState) but this block
treats it like a string; update the writes and broadcast to convert the byte
array to the expected string payload and reset heldBytes as an empty array.
Specifically, before calling enqueueEmulatorWrite and broadcastEvent (which
expects TerminalDataEvent.data as string), convert this.scanState.heldBytes to a
string (e.g., via Buffer/Uint8Array -> text decoding) and pass that string, and
then clear this.scanState.heldBytes = [] and keep this.scanState.matchPos = 0;
adjust any types/signatures if needed where enqueueEmulatorWrite or
TerminalDataEvent are used to accept string produced from the decoded bytes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/desktop/src/main/terminal-host/session.ts`:
- Around line 1055-1063: scanState.heldBytes is now a number[] (per
ShellReadyScanState) but this block treats it like a string; update the writes
and broadcast to convert the byte array to the expected string payload and reset
heldBytes as an empty array. Specifically, before calling enqueueEmulatorWrite
and broadcastEvent (which expects TerminalDataEvent.data as string), convert
this.scanState.heldBytes to a string (e.g., via Buffer/Uint8Array -> text
decoding) and pass that string, and then clear this.scanState.heldBytes = [] and
keep this.scanState.matchPos = 0; adjust any types/signatures if needed where
enqueueEmulatorWrite or TerminalDataEvent are used to accept string produced
from the decoded bytes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 045de3fa-c8d6-4190-b1d4-36759aa342ef

📥 Commits

Reviewing files that changed from the base of the PR and between f7db05c and 6bcaebc.

📒 Files selected for processing (7)
  • apps/desktop/src/main/terminal-host/session.ts
  • apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
  • packages/host-service/src/terminal/terminal.ts
  • packages/shared/src/shell-ready-scanner.test.ts
  • packages/shared/src/shell-ready-scanner.ts
  • packages/shared/src/terminal-title-scanner.test.ts
  • packages/shared/src/terminal-title-scanner.ts

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 2, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

Copy link
Copy Markdown
Contributor

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

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

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="packages/shared/src/terminal-title-scanner.ts">

<violation number="1" location="packages/shared/src/terminal-title-scanner.ts:155">
P2: Storing `subarray()` in scanner state retains the full original chunk buffer; copy the bytes before persisting to avoid unnecessary memory retention.</violation>
</file>

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

Comment thread packages/shared/src/terminal-title-scanner.ts Outdated
- session.ts (v1): the resolveShellReady held-bytes flush was still
  treating heldBytes as a string after the scanner-state type changed
  to number[]. Decode to utf-8 once, pass the string to the v1 emulator
  + event surface, and reset the array properly. (CodeRabbit, valid.)

- terminal-title-scanner.ts: the in-flight OSC buffer was held as a
  `subarray` view into the original chunk's ArrayBuffer, which pinned
  the whole (potentially multi-KB) chunk in memory just to keep a few
  trailing bytes alive across the next call. Copy the slice into a
  fresh tiny buffer instead. (cubic, valid.)

The first fix surfaces a tsc-invocation gotcha: running
`bunx tsc --noEmit -p apps/desktop/tsconfig.json` from a parent dir
silently skipped some files that `cd apps/desktop && bunx tsc --noEmit`
catches. Worth knowing — the same shape of bug could repeat.
Same architectural smell we just fixed at the host↔renderer hop, one
level deeper. The daemon↔host wire was JSON-everything: PTY input/output
bytes got base64'd into a JSON `data` field, sent as a JSON frame, and
base64-decoded on the other side. ~33% bandwidth tax + an encode/decode
pass per chunk in each direction, paid for nothing — base64 was just the
path of least resistance when the wire schema was JSON-only.

Wire format goes from
    [u32 length][JSON]
to
    [u32 totalLen][u32 jsonLen][JSON][optional payload bytes]

OutputMessage and InputMessage drop their `data: string` field; bytes
ride in the frame's binary tail. Every other message looks identical
(jsonLen == totalLen-4, no tail). Protocol bumps from v1 to v2 — no
backward compat, no v1 fallback. Phase 2's auto-update converges daemon
to current on host-service start, so the version-skew window is bounded
to the one upgrade where this lands.

Touches:
- protocol/framing.ts: new wire layout, encoder takes optional payload,
  decoder yields {message, payload} pairs. Payload bytes are copied out
  of the streaming buffer so callers can hold onto them safely.
- protocol/messages.ts: drop `data` from OutputMessage/InputMessage.
- protocol/version.ts: bump to 2.
- Server.ts + handlers.ts: emit/consume bytes via tail.
- DaemonClient.ts: same; output callback receives Buffer view of the
  payload instead of base64-decoded data.
- DaemonSupervisor.ts: drain returns DecodedFrame, not raw JSON.
- Test helpers + suites: WeakMap-keyed payload lookup keeps predicates
  unchanged; `payloadAsString(m)` replaces the old base64 dance.

Verified end-to-end with the full control-plane test (31 cases) over
real Unix sockets — late subscribers, host-service restart, hostile
input, 20 concurrent sessions, partial-frame TCP chunking.
@Kitenite Kitenite changed the title fix(host-service): carry PTY output as bytes end-to-end fix: carry PTY output as bytes end-to-end (renderer + daemon wires) May 2, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/pty-daemon/test/helpers/client.ts (1)

160-165: ⚡ Quick win

Prefer the exported protocol version in the shared handshake helper.

Hardcoding 2 here makes connectAndHello() drift on the next protocol bump and turns a single-version change into a broad test edit.

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

In `@packages/pty-daemon/test/helpers/client.ts` around lines 160 - 165,
connectAndHello currently hardcodes the protocol number (2); instead import and
use the shared exported protocol version constant (e.g., PROTOCOL_VERSION or
whatever the project exports) in the protocols array when sending the hello so
tests automatically follow protocol bumps; locate the connectAndHello function
and replace the literal 2 with the shared exported symbol and ensure the import
for that symbol is added to the top of the file.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/pty-daemon/test/control-plane.test.ts`:
- Around line 268-271: The predicate passed to c.waitFor (used in the test
around c.waitFor(...) with payloadAsString and matching id) assumes a single
output message contains the full UTF-8 glyph; instead, create a small
closure-scoped buffer variable outside the predicate and, inside the predicate
for messages where m.type === "output" && m.id === id, append the raw payload
bytes to that buffer, then decode the concatenated buffer (using the same
decoder used elsewhere or TextDecoder) and test whether the decoded string
includes "🚀"; return true when it does and keep accumulating otherwise so the
test succeeds even if the rocket bytes are split across multiple output frames.

In `@packages/pty-daemon/test/helpers/client.ts`:
- Around line 20-24: payloadAsString currently decodes each frame to UTF-8 per
call (Buffer.from(p).toString("utf8")), which can produce U+FFFD on split
multibyte codepoints; instead keep test helpers byte-oriented or provide a
streaming UTF-8 decoder. Update the payloadAsString helper (and usages reading
from the payloads map) to return raw bytes (e.g., a Buffer/Uint8Array or a
latin1/byte-preserving string) for byte-level assertions, or replace it
with/introduce an alternative helper that uses Node's StringDecoder to
accumulate frames across calls and emit correct UTF-8 only when the decoder has
enough bytes (name the new helper e.g. payloadAsStringStreaming or use
StringDecoder inside payloadAsString and update callers accordingly).

---

Nitpick comments:
In `@packages/pty-daemon/test/helpers/client.ts`:
- Around line 160-165: connectAndHello currently hardcodes the protocol number
(2); instead import and use the shared exported protocol version constant (e.g.,
PROTOCOL_VERSION or whatever the project exports) in the protocols array when
sending the hello so tests automatically follow protocol bumps; locate the
connectAndHello function and replace the literal 2 with the shared exported
symbol and ensure the import for that symbol is added to the top of the file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e55ac5d-d27d-423e-85f7-d32f6559c88c

📥 Commits

Reviewing files that changed from the base of the PR and between 42866ea and 59aa66d.

📒 Files selected for processing (14)
  • packages/host-service/src/daemon/DaemonSupervisor.test.ts
  • packages/host-service/src/daemon/DaemonSupervisor.ts
  • packages/host-service/src/terminal/DaemonClient/DaemonClient.ts
  • packages/pty-daemon/src/Server/Server.ts
  • packages/pty-daemon/src/handlers/handlers.test.ts
  • packages/pty-daemon/src/handlers/handlers.ts
  • packages/pty-daemon/src/protocol/framing.test.ts
  • packages/pty-daemon/src/protocol/framing.ts
  • packages/pty-daemon/src/protocol/messages.ts
  • packages/pty-daemon/src/protocol/version.ts
  • packages/pty-daemon/test/control-plane.test.ts
  • packages/pty-daemon/test/helpers/client.ts
  • packages/pty-daemon/test/integration.test.ts
  • packages/pty-daemon/test/signal-recovery.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/pty-daemon/src/protocol/version.ts

Comment thread packages/pty-daemon/test/control-plane.test.ts
Comment thread packages/pty-daemon/test/helpers/client.ts Outdated
Three layered guards designed to fail loud the moment any future change
slips an encoding hop back into the data path:

1. byte-fidelity.test.ts — runtime canary
   Spins up a real Server with a driveable fake PTY (new spawnPty option
   on ServerOptions). Generates random bytes, injects them via the fake,
   subscribes via real DaemonClient through a Unix socket, and asserts
   SHA-256(received) === SHA-256(sent). Three cases:
     - 64 KB random bytes, varied chunk sizes → live stream byte-perfect
     - 32 KB random bytes accumulated pre-subscribe → replay byte-perfect
     - explicitly-invalid UTF-8 sequences split byte-by-byte → no U+FFFD
   This is the strongest single canary: random bytes naturally include
   sequences that aren't valid UTF-8, so any per-chunk `.toString("utf8")`
   in the relay turns into U+FFFD replacement and the hash diverges.

2. wire-shape.test.ts — structural invariants on encodeFrame output
   Cheap (no socket, no shell), targeted at specific past-mistake shapes:
     - Output frames carry bytes in the binary tail; the JSON header
       must not contain "data".
     - Same for input frames.
     - Control frames have jsonLen === totalLen-4 (no payload).
     - Payload bytes ARE the raw bytes — explicitly NOT their base64 form.

3. no-encoding-hops.test.ts — source-level grep
   Walks the data-path source files and asserts:
     - No `toString("base64")` anywhere in the daemon Server, handlers,
       protocol/messages.ts, protocol/framing.ts, or host-service's
       DaemonClient.
     - No `Buffer.from(.., "base64")` in the same set.
     - OutputMessage/InputMessage do not declare a `data` field.
     - host-service's terminal.ts uses StringDecoder for utf-8 (not
       per-chunk `.toString("utf8")`).
   Comments are stripped before grepping so explanatory text mentioning
   the old patterns doesn't trigger false positives.

Verified the source-level guard catches a real regression by manually
reintroducing `chunk.toString("base64")` in Server.ts — test failed
exactly as intended, then reverted.

Also: ServerOptions gains a `spawnPty?` factory override. Production
leaves it unset (defaults to real node-pty); tests inject a fake to
drive sessions deterministically without a real shell.
…ket test

Address CodeRabbit review on PR #3981: the existing rocket-emoji test
predicate decoded each output frame individually, which would emit
U+FFFD if the 4 bytes of 🚀 ever straddled two daemon `output` frames
— even though the wire bytes are intact. PTY syscalls don't fragment
a 4-byte write in practice, but the predicate not exercising the
boundary case it claims to test is a latent flake.

- New helper `accumulatedOutputAsString(client, id)` concatenates every
  payload for a session, then UTF-8 decodes once. Unambiguous semantics.
- `payloadAsString` keeps its single-frame shape (the other 16 callers
  use it with ASCII markers where per-frame decoding is split-safe by
  construction) and now documents the multi-byte caveat.
- Rocket test switched to `accumulatedOutputAsString`. 31/31 control-
  plane tests still pass.

Other open review threads:
- greptile P2 on shell-ready-scanner perf (`number[]` accumulation):
  acknowledged; not fixing — scanner only runs during the OSC 133
  startup window per session and stops on first match. Not a hot path.
- greptile + cubic on terminal-title-scanner subarray retention:
  already addressed in commit 42866ea. Outdated comments.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/pty-daemon/src/Server/Server.ts (1)

129-209: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject unexpected binary payloads outside input.

payload is only consumed in the input branch. A malformed hello/open/resize/close/list/subscribe/unsubscribe frame with a non-empty binary tail is currently accepted and the bytes are silently dropped, which makes protocol regressions harder to spot and weakens the v2 wire-shape contract.

🛡️ Suggested guard
 private dispatch(
 	conn: ConnState,
 	msg: ClientMessage,
 	payload: Uint8Array | null,
 ): void {
+	const hasPayload = payload !== null && payload.byteLength > 0;
+
+	if (hasPayload && msg.type !== "input") {
+		conn.send({
+			type: "error",
+			message: `unexpected binary payload for ${msg.type}`,
+			code: "EPROTO",
+		});
+		conn.socket.destroy();
+		return;
+	}
+
 	// Handshake must come first.
 	if (conn.negotiated === null) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pty-daemon/src/Server/Server.ts` around lines 129 - 209, The
dispatch function currently ignores a non-null payload for all branches except
"input", so add a guard in dispatch (after ctx = this.handlerCtx() and before
the switch) that if payload !== null and msg.type !== "input" it sends an error
via conn.send({ type: "error", message: "unexpected binary payload", code:
"EPROTO" }) and then destroys the connection with conn.socket.destroy(); this
ensures any malformed hello/open/resize/close/list/subscribe/unsubscribe frames
with trailing bytes are rejected rather than silently dropped.
🧹 Nitpick comments (1)
packages/pty-daemon/test/no-encoding-hops.test.ts (1)

79-90: ⚡ Quick win

Make the UTF-8 canary identifier-agnostic.

This only blocks chunk.toString("utf8") and data.toString("utf8"). Renaming the Buffer variable to buf, payload, etc. would let the same regression through unchanged.

🔍 Suggested tightening
-		expect(src).not.toMatch(/chunk\.toString\(["']utf-?8["']\)/);
-		expect(src).not.toMatch(/data\.toString\(["']utf-?8["']\)/);
+		expect(src).not.toMatch(/\.\s*toString\(\s*["']utf-?8["']\s*\)/);
 		expect(src).toContain('new StringDecoder("utf8")');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/pty-daemon/test/no-encoding-hops.test.ts` around lines 79 - 90, The
test "terminal.ts only uses Buffer.toString(\"utf8\") in side-channel paths"
currently only rejects literal `chunk.toString("utf8")` and
`data.toString("utf8")`, so rename-safe Buffer variables can bypass it; change
the negative match assertions to detect any identifier invoking .toString with a
UTF-8 argument (e.g., a regex matching
"<identifier>.toString('utf8'|\"utf8\"|with optional dash/quotes>") instead of
the hardcoded `chunk`/`data` names, keeping the positive check for 'new
StringDecoder("utf8")' and the same source read call
(read("packages/host-service/src/terminal/terminal.ts")) so the test fails on
any plain Buffer.toString("utf8") usage regardless of variable name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/pty-daemon/src/Server/Server.ts`:
- Around line 129-209: The dispatch function currently ignores a non-null
payload for all branches except "input", so add a guard in dispatch (after ctx =
this.handlerCtx() and before the switch) that if payload !== null and msg.type
!== "input" it sends an error via conn.send({ type: "error", message:
"unexpected binary payload", code: "EPROTO" }) and then destroys the connection
with conn.socket.destroy(); this ensures any malformed
hello/open/resize/close/list/subscribe/unsubscribe frames with trailing bytes
are rejected rather than silently dropped.

---

Nitpick comments:
In `@packages/pty-daemon/test/no-encoding-hops.test.ts`:
- Around line 79-90: The test "terminal.ts only uses Buffer.toString(\"utf8\")
in side-channel paths" currently only rejects literal `chunk.toString("utf8")`
and `data.toString("utf8")`, so rename-safe Buffer variables can bypass it;
change the negative match assertions to detect any identifier invoking .toString
with a UTF-8 argument (e.g., a regex matching
"<identifier>.toString('utf8'|\"utf8\"|with optional dash/quotes>") instead of
the hardcoded `chunk`/`data` names, keeping the positive check for 'new
StringDecoder("utf8")' and the same source read call
(read("packages/host-service/src/terminal/terminal.ts")) so the test fails on
any plain Buffer.toString("utf8") usage regardless of variable name.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: be031f3d-7315-4699-9dba-1c7c81377fba

📥 Commits

Reviewing files that changed from the base of the PR and between 59aa66d and c31dfe7.

📒 Files selected for processing (4)
  • packages/pty-daemon/src/Server/Server.ts
  • packages/pty-daemon/src/protocol/wire-shape.test.ts
  • packages/pty-daemon/test/byte-fidelity.test.ts
  • packages/pty-daemon/test/no-encoding-hops.test.ts

A pass over the PR-touched files to drop comments the code already
explains and to tighten the ones that earn their keep:

- shell-ready-scanner.ts: drop "OSC 133;A as raw bytes" — the constant
  name says it.
- messages.ts: collapse the InputMessage / OutputMessage doc blocks to
  one line each; both said the same thing in three lines.
- handlers.ts: drop the "adapt to whatever Pty expects" comment; the
  Buffer.from(payload) call is enough.
- terminal.ts: trim the cast-helper rationale to two lines; drop the
  redundant "Scan for OSC 133;A" comment (function name says it);
  tighten the portManager side-channel comment.
- terminal-title-scanner.ts: drop the "Same memory-retention concern"
  duplicate — copySlice's name plus the comment on its definition is
  enough; tighten the trailing-ESC comment.

Net: 35 lines removed, 16 added. Tests + typecheck still green.
@Kitenite Kitenite merged commit 9016225 into main May 2, 2026
8 checks passed
@Kitenite Kitenite deleted the faceted-minnow branch May 2, 2026 17:53
Kitenite added a commit that referenced this pull request May 2, 2026
The on-disk snapshot used to be JSON-with-base64: PTY ring buffer bytes
were base64-encoded so they could ride inside JSON. Now the snapshot is
a sequence of length-prefixed frames using the same `encodeFrame` /
`FrameDecoder` already used on the wire — JSON metadata in the header,
raw buffer bytes in the binary tail.

Why: base64 was the last on-disk byte-encoding hop in the codebase.
The wire path went binary in #3981; the snapshot was the only place
left where bytes had to be string-shaped. With this commit there's
exactly one byte-encoding format across the daemon ↔ host ↔ disk
surfaces.

Practical wins:
- ~25 % less disk usage per snapshot (no base64 expansion)
- zero encode/decode passes per byte
- Server.adoptSnapshot reads `s.buffer: Uint8Array` directly
- snapshot file extension flips from .json → .snap (it's no longer JSON)

`SerializedSession.buffer` changes type (`string` → `Uint8Array`); only
in-process callers, no on-disk back-compat to preserve (snapshots are
transient files written by the predecessor and read by the successor
moments later).
Kitenite added a commit that referenced this pull request May 2, 2026
…#3971)

* feat(pty-daemon): expose master fd from Pty adapter

Phase 2 fd-handoff requires passing the kernel master fd of each PTY to
the successor daemon process via stdio inheritance. node-pty does not
publicly expose this fd; reach into its private `_fd` property and
assert at spawn time so a future node-pty bump can't silently break
handoff months from now.

node-pty stays pinned to 1.1.0 exactly — Phase 0 harness validated this
version's `_fd` shape; the assert in NodePtyAdapter.getMasterFd surfaces
a clear error if the contract ever shifts.

* feat(pty-daemon): handoff control-fd protocol

Tiny protocol for the daemon-to-successor control channel during
fd-handoff. The successor sends `upgrade-ack { successorPid }` once it
has adopted the inherited PTY master fds, or `upgrade-nak { reason }`
on a soft failure. Old daemon awaits one or times out.

Travels over a dedicated stdio fd in the successor's inherited stdio
array — NOT exposed to clients. Reuses the existing length-prefixed
JSON framing so it shares encodeFrame / FrameDecoder.

Client wire protocol (messages.ts, version.ts) is untouched.

Also extends mock Pty implementations in handlers.test.ts and
SessionStore.test.ts to satisfy the new getMasterFd() interface
member added in the previous commit.

* feat(pty-daemon): handoff snapshot writer/reader

The handoff snapshot is the on-disk bookkeeping the successor reads to
rebuild SessionStore state. Kernel-side state (PTY master fds) flows
through the spawned successor's stdio array; this snapshot carries the
session id ↔ fdIndex mapping plus per-session metadata and replay buffer.

Atomic write via tmp+rename so the successor only ever sees a complete
file. Exited sessions are filtered out — they have no live PTY fd to
inherit, and their exit event has already been delivered to renderers
(see Server.onExit's delete-on-exit behavior).

`version: 1` is forward-compat scaffolding only — snapshots are written
and consumed by the same daemon-pair on the order of milliseconds, so we
don't need cross-version compatibility yet.

* feat(host-service): manifest fields for handoff state

Adds three optional fields to PtyDaemonManifest to track in-flight
fd-handoff state:

- handoffInProgress: true between snapshot-write and successor's bind
- handoffSnapshotPath: where the successor reads its bookkeeping
- handoffSuccessorPid: the spawned successor's pid

All optional + lenient parsing — older host-service builds that don't
know these fields ignore them, and a malformed handoff field doesn't
make the whole manifest unreadable. The handoff state is advisory; if
it gets corrupted, the supervisor can always fall back to the existing
adopt-or-spawn path.

* feat(pty-daemon): adopt PTY sessions from inherited master fd

AdoptedPty wraps a PTY master fd inherited from a predecessor daemon
via stdio. The successor doesn't have a node-pty IPty for these
sessions (forkpty was never run; the fd already existed), so we build
a thin adapter directly on the fd:

- read via fs.createReadStream (autoClose: false — supervisor owns)
- write via fs.createWriteStream
- kill via process.kill(pid)
- onExit: read-stream end/error OR PID-liveness poll, whichever first

Resize on adopted sessions is a known gap — TIOCSWINSZ requires native
ioctl access. Today resize() updates meta cols/rows but leaves kernel
window size untouched. Visible only if a user resizes a session that
was carried across an upgrade. Follow-up: koffi or N-API helper.

Test coverage: API-surface checks. End-to-end I/O on an adopted fd
will be validated in the cross-process handoff integration test
(Step 10) — testing in-process is unreliable because node-pty's native
worker reads from the master fd in parallel with adoptFromFd's stream.

* feat(pty-daemon): cross-process daemon-binary handoff

Phase 2 sender + receiver: a daemon process spawns a successor that
inherits all live PTY master fds via stdio, adopts them via the
already-built `adoptFromFd`, then takes over the socket. The original
shell PIDs survive the daemon binary swap.

**Wire (client-facing):** new `prepare-upgrade` request and
`upgrade-prepared { result }` reply. The supervisor calls
`DaemonClient.prepareUpgrade()` to drive the flow; result carries the
successor's pid on success or a reason string on failure. Client
protocol stays at v1 — the new pair is additive.

**Daemon-to-successor:** Node IPC channel (stdio[3]='ipc'). The
predecessor sends nothing on the IPC; the successor sends one of
`upgrade-ack { successorPid }` or `upgrade-nak { reason }`. Predecessor
times out after 5s.

**Sequencing landmines this commit handles:**

1. server.close() unlinks the bound socket path. The successor must
   bind only AFTER the predecessor has unlinked, otherwise the
   predecessor's unlink yanks the path entry from under the successor's
   chmod, raising ENOENT. We wait for the IPC channel to disconnect
   (= predecessor process.exit) before binding.
2. The dispatch handler that called prepareUpgrade is responsible for
   sending upgrade-prepared back over the supervisor's wire. Closing
   the server inside prepareUpgrade beat the reply and timed out the
   supervisor. We schedule close+exit via `setImmediate` after the
   dispatcher has had its turn.
3. process.execArgv (--experimental-strip-types in dev/tests) must
   forward to the successor or it can't load .ts files.

**Tests:** test/handoff.test.ts spawns a real daemon binary, opens a
session that sleeps 30s, sends prepare-upgrade, verifies the successor
binary lists the same session id with the same shell pid still alive.
This is the core Phase 2 success criterion.

* feat(host-service): DaemonSupervisor.update() with fd-handoff

Wraps the daemon-side prepare-upgrade wire call with the supervisor-side
bookkeeping needed to swap in the successor cleanly:

- Suppress crash-respawn for the predecessor's imminent exit
  (`stopping` set + stop adopted-liveness)
- Mark `handoffInProgress: true` in the manifest so a host-service
  crash mid-handoff is debuggable
- On `upgrade-prepared { ok: true }`: probe successor version, swap
  manifest + instances entry to the new pid, restart adopted-liveness
  on the successor (we don't have a child handle for it)
- On failure: restore manifest, clear `stopping`, re-arm liveness on
  the still-alive predecessor

Distinct from `restart()` — restart kills sessions, update preserves them.
The renderer wires "Update" to this path and falls back to restart() only
on failure.

* feat(host-service): terminal.daemon.update tRPC procedure

Wires the supervisor's `update()` to the renderer surface as a
mutation that mirrors `restart()` shape but preserves sessions on
success. The renderer keeps `restart` as the explicit force-restart
fallback; `update` is the new default UX.

* feat(desktop): wire Update button to fd-handoff flow

Renderer changes for Phase 2's "preserve sessions across upgrades" UX:

- New primary "Update daemon" button when update is pending. Calls
  terminal.daemon.update; on success, sessions stay alive and a toast
  reports the version bump.
- Existing "Restart daemon" stays as the secondary, force-close path
  ("Force restart" when an update is pending). The existing confirmation
  dialog now nudges users toward Update for the non-destructive path.
- New "Update couldn't preserve sessions" failure dialog. Surfaces the
  reason from supervisor.update (snapshot write failed, successor never
  acked, transport error). Two paths from there: Force update (calls
  restart) or Cancel — keeps sessions alive on the predecessor.

This is decision D5 from the Phase 2 /decide walkthrough: surface
handoff failure to the user, let them choose force-update or cancel,
never silently destroy sessions on a soft failure.

* test(host-service): supervisor.update() real-spawn handoff coverage

End-to-end supervisor coverage at the integration level:

1. update() preserves live sessions across daemon binary swap
   - Spawn fresh daemon via supervisor
   - Open a session over the wire, capture the shell pid
   - Call sup.update(orgId)
   - Verify successor pid differs from predecessor, predecessor is
     dead, successor is alive
   - Reconnect to the (same) socket and assert the session is still
     listed with its original shell pid

2. update() returns ok:false with "no daemon running" when no instance
   exists — guards against the renderer firing update() before
   bootstrap completes.

Also fixes an AdoptedPty teardown leak: with `autoClose: false` on the
read/write streams, the inherited fd would keep the event loop alive
after the shell exits. AdoptedPty's onExit now destroys both streams
and closes the fd explicitly. (Without this, normal daemon shutdown
of an adopted-session daemon stalls waiting for stale handles to drain.)

* feat(desktop): always show Update daemon button when daemon is up

Update is non-destructive — there's no reason to gate it behind an
`updatePending` flag. Even with no version difference, clicking Update
spawns a fresh successor daemon process that adopts existing sessions
via fd-handoff. Useful in dev for verifying handoff plumbing without
faking a version mismatch, and arguably better UX in prod too — Update
always works, Force restart is the explicit destructive opt-in.

The version-pending badge in the status row stays gated on
updatePending — that signal is meaningful UI; the button gate isn't.

* fix(pty-daemon): surface cwd / shell in spawn errors

node-pty's "posix_spawnp failed" message swallows errno entirely,
leaving callers with zero context for diagnosis. Pre-flight the
most-common failure mode (missing cwd) and annotate any other native
failure with the cwd + shell that triggered it.

Common cause: a workspace's worktreePath gets deleted (manual rm,
worktree pruned) but the workspace record stays in the local DB.
Opening a terminal in it sends the missing cwd to the daemon. With
this change, the renderer's terminal-error toast says "spawn: cwd
does not exist: /path/to/dead-worktree" instead of the generic native
error.

* fix(pty-daemon): switch handoff signal to argv — bundlers DCE env checks

Phase 2 handoff worked end-to-end against source-direct integration
tests and Bun-built bundles, but failed in the deployed Electron build.
Root cause: both Bun's bundler AND electron-vite's esbuild statically
inline `process.env.<KEY>` references at build time, constant-fold the
unset-at-build-time comparison `undefined === "1"`, then dead-code-
eliminate the entire receiver branch — including the runHandoffReceiver
function. Bracket notation `process.env["KEY"]` doesn't help; both
bundlers see through it.

Manually verified end-to-end in a real dev build today: spawned 3
sessions, clicked Update three times in succession, all sessions
preserved (same shell PIDs, no flicker beyond the WS reconnect).

Changes:
- Mode signal moves from `SUPERSET_PTY_DAEMON_HANDOFF=1` env to
  `--handoff` argv flag. Snapshot path + socket path also move to argv
  (`--snapshot=...` `--socket=...`). argv is fully dynamic and survives
  every bundler we run.
- The desktop daemon entry shim at apps/desktop/src/main/pty-daemon/
  index.ts was an ENTIRELY SEPARATE copy of the package's old main.ts
  that electron-vite bundles independently — so it had none of the
  Phase 2 receiver code. Rewritten to mirror the package's main.ts with
  full handoff support.
- Snapshot helpers (clearSnapshot, readSnapshot, writeSnapshot) and
  related types are now exported from @superset/pty-daemon for the
  desktop shim to consume.
- Diagnostic stderr writes in both predecessor's prepareUpgrade and
  successor's runHandoffReceiver. Kept in production code: cheap, and
  this whole class of bug was invisible without them.

Test gap noted: integration tests ran the .ts source via
`node --experimental-strip-types` (no bundling) or the Bun-built
package output (different DCE behavior than esbuild). Neither covered
apps/desktop/dist/main/pty-daemon.js, where the actual production
failure mode lives. Follow-up: add a CI-runnable smoke that builds the
desktop bundle and greps for `runHandoffReceiver` + `--handoff`
markers, so future bundler regressions fail fast.

* test(desktop): smoke-check pty-daemon bundle for handoff markers

Phase 2's fd-handoff broke in the deployed Electron build because
both Bun's bundler and electron-vite's esbuild did dead-code-elimination
on `process.env.X === "Y"` checks at build time, stripping the handoff
receiver branch from `apps/desktop/dist/main/pty-daemon.js`. The bug
was invisible to existing tests (they ran source-direct or the Bun
bundle, never the desktop-shim bundle).

Adds a post-`compile:app` smoke that greps the bundle for runtime
markers Phase 2 depends on:
- `runHandoffReceiver` — the successor entry function
- `--handoff` (≥2 times) — argv flag at the spawn + receiver sites
- `upgrade-ack` — IPC handshake message type
- `adoptSnapshot` — Server's snapshot replay path
- `adoptFromFd` — Pty wrapping for an inherited fd

If any marker is missing, the build fails fast with a diagnostic
pointing at the runtime conditions that need to survive bundling.
This makes future bundler regressions visible at build time, not
manual-QA time.

Wired into apps/desktop's `compile:app` so it runs in production
builds and CI without an extra step. Standalone via
`bun run check:pty-daemon-bundle` for ad-hoc verification.

* feat(host-service): kill stale daemon on dev-mode startup

Adoption is the right behavior in production — host-service can crash
or restart and pick up its still-running daemon, preserving every PTY
session. In dev (bun dev), it's a footgun: a leftover detached daemon
from the previous bun dev session has the OLD bundle code, so devs
edit pty-daemon source, restart bun dev, and silently still talk to
the stale daemon. Code changes that ride only on the daemon binary
(handoff plumbing, snapshot writes, etc.) appear to do nothing.

NODE_ENV != "production" → kill any existing daemon for the org
(SIGTERM, escalate to SIGKILL after 1s if still alive) before
adopt-or-spawn. Manifest gets removed too. Next bootstrap always
spawns fresh from the latest bundle. Idempotent when no daemon is
running.

Production path is unchanged.

* feat(pty-daemon): TIOCSWINSZ on adopted PTY fds via stty

AdoptedPty.resize previously only updated meta cols/rows but left the
kernel-side window size unchanged — visible to users as stale dims
after dragging the terminal pane border on a session that was carried
across a daemon upgrade. node-pty issues TIOCSWINSZ via its native
binding, but adopted sessions don't go through that path.

Workaround without adding a native FFI dependency: spawn `stty` with
the master fd as its stdin. stty(1) issues TIOCSWINSZ on its own stdin
by default, so the master inherits the resize. One spawn per resize is
fine — resize is rare (xterm.js throttles, users only resize via
drag).

Best-effort: if stty isn't available or the syscall fails, meta stays
correct for next reattach; the kernel-side just stays stale until then.

* feat(host-service): auto-update daemon on adopt with version drift

When host-service boots and adopts a daemon whose runningVersion <
EXPECTED_DAEMON_VERSION (i.e. host-service was just upgraded but the
detached daemon is still on the old binary), kick off `supervisor.update()`
in the background. Sessions survive on success — the new daemon adopts
all PTY masters via fd-handoff. On failure, leave the old daemon
running and let the user retry via the Update button. No fallback to
force-restart; we never auto-destroy sessions.

Concurrency:
- New `updateInFlight` map coalesces concurrent `update()` calls per
  org. Both auto-update and a manual Update button click hit the same
  entry point — without this, two simultaneous calls would each try
  to handoff a daemon that's already mid-handoff. Second caller gets
  the cached promise and observes the same outcome.

Telemetry events: `pty_daemon_auto_update_attempt`, `pty_daemon_auto_update_ok`,
`pty_daemon_auto_update_failed { reason }`.

Constructor option `autoUpdate?: boolean` (default true) for tests
that intentionally adopt a stale daemon and don't want a real handoff
racing the assertion.

Tests:
- 3 unit tests for the in-flight cache (coalesce, recycle after settle,
  per-org separation)
- 1 real-spawn integration test: pin a daemon to v0.0.1, fresh
  supervisor adopts, asserts pid swap to a successor of the bundled
  version within 8s, predecessor dead within another 2s.
- Existing real-spawn tests now force NODE_ENV=production in the test
  fixture so the dev-mode-kill behavior added earlier doesn't preempt
  the adoption paths these tests assert.

* chore(pty-daemon): include byte-fidelity + handoff in test scripts

`bun run test` only ran src/* — the integration tests added in main
(no-encoding-hops, byte-fidelity) and in this branch (handoff) sat
outside both `test` and `test:integration`. Move them into the right
runners so CI exercises them.

Also: lint:fix reformats across the renderer settings panel and the
DaemonSupervisor test files.

* refactor(pty-daemon): handoff snapshot uses wire framing, drop base64

The on-disk snapshot used to be JSON-with-base64: PTY ring buffer bytes
were base64-encoded so they could ride inside JSON. Now the snapshot is
a sequence of length-prefixed frames using the same `encodeFrame` /
`FrameDecoder` already used on the wire — JSON metadata in the header,
raw buffer bytes in the binary tail.

Why: base64 was the last on-disk byte-encoding hop in the codebase.
The wire path went binary in #3981; the snapshot was the only place
left where bytes had to be string-shaped. With this commit there's
exactly one byte-encoding format across the daemon ↔ host ↔ disk
surfaces.

Practical wins:
- ~25 % less disk usage per snapshot (no base64 expansion)
- zero encode/decode passes per byte
- Server.adoptSnapshot reads `s.buffer: Uint8Array` directly
- snapshot file extension flips from .json → .snap (it's no longer JSON)

`SerializedSession.buffer` changes type (`string` → `Uint8Array`); only
in-process callers, no on-disk back-compat to preserve (snapshots are
transient files written by the predecessor and read by the successor
moments later).

* fix(daemon): address PR review feedback

P1 — real bugs flagged by greptile / cubic / coderabbit:

- Strip SUPERSET_PTY_DAEMON_VERSION from the successor's env in
  Server.prepareUpgrade. The supervisor pins this when spawning the
  daemon; without the strip, the successor inherits the *old* version
  pin, the supervisor sees no version change, marks updatePending
  again, and the auto-update loop never converges.

- Wait for the predecessor PID to actually exit before probing the
  new socket version (DaemonSupervisor.waitForPidExit). Pre-fix the
  probe could connect to the still-alive predecessor (whose
  finalizeHandoff is a setImmediate + 50 ms exit timer) and record
  its old version as the successor's, leaving updatePending true.

- Wrap readSnapshot() in the try/catch that sends upgrade-nak. A
  malformed/missing snapshot used to bail via main().catch(...) without
  notifying the predecessor; the sender then burned the full 5 s ack
  timeout instead of getting an immediate failure. Mirrored fix in both
  the desktop bundle entry and packages/pty-daemon/src/main.ts.

- Add an 'error' listener to AdoptedPty.writer. Streams without an
  error listener crash the process when they emit one; a slave-side
  close mid-write would take down the whole daemon. Drive the same
  exit path as reader errors instead.

- Move the cwd-not-directory check out of the surrounding try/catch
  in Pty.spawn so the error doesn't get re-wrapped into a confusing
  "spawn: cwd not accessible (spawn: cwd is not a directory)" string.

P2 — polish:

- Server.prepareUpgrade unlinks the snapshot on failed handoff
  instead of leaving stale .snap files in /tmp.

- main.ts header comment now matches the argv-based mode signal
  (the env-var fallback was retired in 0ecb2b5).

- check-pty-daemon-bundle.ts uses fileURLToPath instead of
  URL.pathname so it round-trips on Windows (drive-letter paths).

* chore: bump pty-daemon 0.1.0 → 0.2.0 (fd-handoff feature)

Phase 2 fd-handoff is a feature addition. Without the bump, in-the-wild
0.1.0 daemons (no handoff support) would compare equal to the new 0.1.0
bundle and `updatePending` would stay false — the supervisor would never
prompt to swap them out. Bumping forces every existing 0.1.0 adoption to
resolve `updatePending=true` on first host-service start after deploy,
and the new auto-update-on-adopt path (a1eeb2c) carries them across.

Lockstep: pty-daemon/package.json#version + EXPECTED_DAEMON_VERSION.

* fix(terminal): suppress replay on WS reconnect — kills dup-on-swap

When the daemon disconnects (handoff or crash), host-service force-closes
every renderer WS to trigger the renderer's auto-reconnect. The renderer's
xterm survives that reconnect with all of its scrollback intact — but
host-service was unconditionally subscribing to the daemon with
`replay: true`, dumping the entire ring buffer onto the new WS. Result:
xterm wrote bytes it had already received during live streaming, and the
visible terminal showed everything twice (banner doubled, conversation
doubled, etc.). The reproducer the user hit was clicking "Update daemon"
during a live Claude Code session and watching the entire transcript
re-print on top of itself.

Fix is end-to-end:

- Renderer signals `?replay=0` on every WebSocket connect AFTER the first
  successful open. The transport tracks `_hasEverOpened`; on first connect
  it omits the param (server defaults to replay=true so cold-start works).

- WS upgrade handler reads `?replay`, plumbs it as `replayOnAdoption`
  through createTerminalSessionInternal to daemon.subscribe.

- Tradeoff: bytes the PTY produced during the WS-down window are not
  replayed. Sub-second on a daemon swap; longer on a host-service
  restart. Closing that window properly needs a "since byte N" cursor
  in the wire — out of scope for this PR.

Tested via the existing terminal.adoption.node-test.ts e2e harness:
new test asserts that an adopted session with replayOnAdoption=false
sees no buffered bytes, and that live output keeps flowing post-reattach.
Verified the test fails when the fix is reverted (replay: replayOnAdoption
→ replay: true) — the sentinel arrives via replay.

* fix(daemon): "Update daemon" stuck on UPDATE AVAILABLE after success

User-facing symptom: badge shows "0.1.0 → 0.2.0 pending" forever even
though the toast confirms the update succeeded ("Now running 0.2.0
(was 0.1.0). All sessions preserved."). Clicking Update again does
nothing. Repro: any host-service running 0.2.0 that adopted a 0.1.0
predecessor — i.e. every user on the first install of this PR.

Three independent bugs colluded; fixing all three:

1. Successor inherits SUPERSET_PTY_DAEMON_VERSION from an OLD-bundle
   predecessor. The env-strip in Server.prepareUpgrade only kicks in
   when the predecessor is the new bundle — but for the first upgrade
   the predecessor IS the old bundle, which spawns the successor with
   `env: process.env` and the supervisor-pinned old version goes through.
   Fix: successor's main.ts/index.ts ignore env in handoff mode and
   read the bundle's own package.json (or DAEMON_PACKAGE_VERSION
   constant for the electron-vite bundle, which can't readFileSync
   package.json at runtime).

2. The desktop bundle entry was hardcoding `?? "0.1.0"` as a fallback.
   That falls behind every version bump. Replace with a single
   exported constant `DAEMON_PACKAGE_VERSION` in @superset/pty-daemon
   that's hand-edited in lockstep with package.json#version.

3. waitForPidExit gates on predecessor death but the successor's
   bind happens AFTER its `await disconnect` resolves (= predecessor
   exit). One probe in that gap returns ECONNREFUSED → null →
   runningVersion="unknown", updatePending stays whatever it was
   pre-upgrade. Replace the single probeDaemonVersion call with
   probeDaemonVersionWithRetry that polls through the bind window.

Regression test in DaemonSupervisor.node-test.ts: spawns a predecessor
with `SUPERSET_PTY_DAEMON_VERSION=0.0.1-stale`, calls sup.update(),
asserts running ≠ "0.0.1-stale", running ≠ "unknown", pending=false.
Verified the test fails with each of the three fixes reverted in turn,
and passes 5/5 runs with all three in place.

* test(daemon): pin "auto-update failure must not disrupt sessions" contract

Auto-update fires on every host-service start when the adopted daemon
is older than the bundle. It's the heavy path — the most user-traveled
code that touches live PTYs. The pre-existing tests cover the success
path (sessions survive a clean swap); these add the failure modes.

Three new tests, all framed around real production cases:

1. **Unit (DaemonSupervisor.test.ts)**: when runUpdate returns ok:false,
   the predecessor's instance record stays in `instances`. The supervisor
   still reports the OLD version + pending=true. The user's shells live
   in that predecessor process; if we ever overwrote the entry on
   failure we'd lose track of them.

2. **Unit (same)**: when runUpdate THROWS (transport-level error,
   ECONNRESET mid-prepareUpgrade), same contract — instance untouched.

3. **Integration (DaemonSupervisor.node-test.ts)**: heavy-path real
   case. Spawn a real predecessor at 0.0.1 with a live shell session.
   Adopt with autoUpdate=true. Inject a runUpdate failure (simulates
   any of: snapshot ENOSPC, successor crash on adoptFromFd, IPC stall).
   After the failure fires:
     - predecessor process is still alive
     - the live shell session is still listed and attachable
     - shell pid is unchanged
     - status reports running=0.0.1 pending=true (user can retry)

Plus a fourth, simpler test:

4. **Integration**: auto-update with ZERO live sessions completes
   cleanly. Easy to break under refactors that assume
   snapshot.sessions.length > 0.

This bakes the "minimal disruption" promise into the test suite so
future changes to the auto-update flow can't silently regress to
killing user shells when something goes wrong.

* fix(daemon): address PR review feedback (round 2)

Three real findings from coderabbit/cubic, plus minor cleanups:

1. **Replay-suppression test was timing-flaky.** The assertion observed
   `pty.onData` (which subscribes with `replay:false` separately on the
   same DaemonClient socket). It only caught the bug by accident — the
   late-attaching local callback received the fanned-out replay bytes.
   Switched the assertion to `session.bufferBytes`, which is the direct
   signal: the primary subscription writes incoming chunks into
   session.buffer for WS broadcast, so non-zero bufferBytes after a
   `replayOnAdoption: false` adopt = bug. Verified by reverting the
   one-line fix and watching the test fail with the exact sentinel
   bytes ("got 162 bytes (first chunk: 'echo noreplay-sentinel-...')").

2. **`_hasEverOpened` set on socket open instead of first byte.** A WS
   that opens cleanly but closes before any output arrives left
   `_hasEverOpened=true`; the next reconnect would pass `?replay=0` and
   the empty xterm would never receive the buffer. Renamed to
   `_hasReceivedBytes` and flipped on the first binary-frame write into
   xterm. A no-bytes WS lifecycle now correctly asks for replay on
   reconnect.

3. **`waitForPidExit` silent timeout.** On timeout, the original
   implementation fell through and the probe ran anyway. With probe-
   retry that could re-introduce the predecessor-version recording
   bug — predecessor wedged but alive, probe connects, gets old
   version. Now returns a boolean; on timeout, update() bails with
   `ok:false` and a useful reason. The wedged-predecessor case is
   rare in practice (predecessor's finalizeHandoff is close + 50ms
   exit) but the silent fallback is the wrong default.

Plus:

4. Single `Date.now()` for successor's `startedAt` so the in-memory
   instance and the on-disk manifest agree to the millisecond.

5. Setup of the auto-update-failure integration test moved inside the
   try/cleanup block — a spawn that succeeds but is followed by a
   throw during session-open won't orphan the daemon child anymore.

* chore: deslop comment surface across the cake-saguaro changes

Net: 212 deletions, 87 insertions across 10 files. No behavior changes;
all 453 unit + 12 daemon-integration + 7 adoption-e2e tests still pass.

What was trimmed:
- Call-site comments that re-explained their helper's docstring
  (waitForPidExit, probeDaemonVersionWithRetry, replayOnAdoption JSDoc).
- Wishful comments that didn't match the code (Server.ts: "log child
  stderr separately" before code that just logs exit code).
- Multi-paragraph history-of-the-test in terminal.adoption.node-test.ts
  — the new assertion stands on its own; the obsolete-approach
  postmortem was clutter.
- "auto-update fires on every adopt with version drift / heavy path"
  said three different ways across describe block, test header, and
  inline.
- The stale "scriptPath points to a missing file" comment in the
  auto-update-fail integration test (the test uses runUpdate override,
  not scriptPath injection — comment lied).

What was extracted:
- seedPredecessor + getInstancePid helpers in DaemonSupervisor.test.ts:
  removed two ~20-line `(sup as unknown as ...).instances.set(...)`
  blocks per test; both auto-update-failure tests now read as 8-10 lines
  of the actual assertion they make.

What was kept:
- Real why-comments: env-strip rationale (1-line), readPackageVersion
  in handoff mode (1-line), writer-error listener (1-line), atomic-
  rename writeSnapshot, snapshot file-format header.
- Critical-contract assertions in the auto-update-failure test.

* test(daemon): lockstep guard for the three version sources

Three places hand-edited together for every daemon version bump:
  - packages/pty-daemon/package.json#version
  - packages/pty-daemon/src/index.ts#DAEMON_PACKAGE_VERSION
    (inlined into the desktop bundle by electron-vite — package.json
    can't be readFileSync'd at runtime in a bundled binary)
  - packages/host-service/src/daemon/expected-version.ts#EXPECTED_DAEMON_VERSION
    (what host-service compares running daemon against; drift here =
    false updatePending or false "running == expected")

The 0.1.0 → 0.2.0 bump in this PR was nearly the exact mistake this
test catches: hand-edit one, ship without the others, the bundle
silently reports the wrong version. Verified the test fails when any
of the three sources drifts.

Also tightened expected-version.ts's lockstep comment now that the
test replaces the "rely on PR review" hand-wave.

* refactor(daemon): single source of truth for daemon version

Three hand-edited copies of "what version is the daemon" collapse to
one: pty-daemon/package.json. The other two now derive at compile time
via JSON imports.

  packages/pty-daemon/src/index.ts
    DAEMON_PACKAGE_VERSION = packageJson.version  (was: "0.2.0" literal)

  packages/host-service/src/daemon/expected-version.ts
    EXPECTED_DAEMON_VERSION = ptyDaemonPackageJson.version  (was: literal)

  packages/pty-daemon/src/main.ts
    DAEMON_VERSION = packageJson.version  (replaces readPackageVersion()
    helper — 11 lines + fileURLToPath/fs/path imports gone)

Bundlers (Bun for the standalone, electron-vite for desktop) inline
the JSON at build time, so the runtime cost is identical to the
literals. Verified: all 453 host-service unit + 12 daemon integration
+ 7 adoption-e2e tests still pass; daemon bundle still reports 0.2.0
end-to-end.

Net effect for future bumps: edit pty-daemon/package.json, rebuild,
done. The lockstep test (added in the prior commit) is dropped because
drift is now structurally impossible — the constants don't exist as
independent values to drift from.

Tradeoff acknowledged: every daemon-version bump now forces an
auto-update for users on the older version (no more "ship a quiet
daemon patch"). Discussed and accepted — daemon bumps are expected to
be rare and significant in this codebase, so the simpler model wins.

Required pty-daemon/package.json#exports to add "./package.json":
"./package.json" so host-service can import it (Node's exports field
is deny-by-default for subpaths).
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