@adhdev/session-host-core 1.0.60-rc.7 → 1.0.60-rc.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -456,6 +456,47 @@ declare class SessionRingBuffer {
456
456
  text: string;
457
457
  }): void;
458
458
  private trim;
459
+ /**
460
+ * TRIM-BOUNDARY repair. Eviction drops whole chunks, and a chunk boundary is
461
+ * a PTY read boundary — an arbitrary byte offset with no relationship to the
462
+ * structure of the stream. So the chunk that becomes the new oldest can begin
463
+ * partway through something the sender wrote atomically, and `snapshot()`
464
+ * joins from exactly there.
465
+ *
466
+ * The consumer is the browser terminal: "Load older terminal output" asks
467
+ * with `sinceSeq: 0`, which makes the daemon skip the emulator viewport and
468
+ * hand this raw text straight to xterm (see `mergeRuntimeSnapshot`). xterm
469
+ * then parses a stream that starts mid-token, which is how the reported
470
+ * screenshot got orphaned `.` and `5` glyphs floating above the output and a
471
+ * large blank band at the top:
472
+ *
473
+ * drop 5 chars -> "[HClaude Code v2.1.220…" the CSI introducer is gone,
474
+ * so `[H` prints literally
475
+ * drop 12 chars -> "2mClaude Code v2.1.220…" half an SGR prints literally
476
+ * drop 46 chars -> the leading `\x1b[2J\x1b[H` never arrives, so the screen
477
+ * is never cleared/homed and row placement collapses
478
+ * byte cut -> a torn 3-byte Hangul sequence decodes to U+FFFD
479
+ *
480
+ * So walk the head of the new oldest chunk forward to the first offset that
481
+ * is safe to start parsing at, and drop the partial prefix. Losing a few
482
+ * bytes of already-evicted context is strictly better than injecting literal
483
+ * garbage into the viewport.
484
+ *
485
+ * Two independent boundary classes have to be handled, and neither subsumes
486
+ * the other:
487
+ *
488
+ * 1. Character encoding. Chunks are JS strings, so a torn multi-byte UTF-8
489
+ * sequence has already decayed into U+FFFD (or, for astral characters, a
490
+ * lone surrogate) by the time it gets here. This mirrors the protection
491
+ * `createLineParser` grew for the IPC socket path in
492
+ * `ipc-line-parser-utf8.test.ts`; that layer can hold bytes back and
493
+ * re-join them because it owns both sides of the split, whereas here the
494
+ * other half is already gone, so dropping is the only repair available.
495
+ * 2. Escape sequences. A CSI/OSC/SS3 can be cut anywhere, and unlike the
496
+ * encoding case the leftover bytes are all perfectly printable — which is
497
+ * precisely why the corruption is visible rather than silent.
498
+ */
499
+ private healHead;
459
500
  }
460
501
 
461
502
  declare class SessionHostRegistry {
@@ -549,8 +590,16 @@ declare function getDefaultSessionHostEndpoint(appName?: string, options?: Sessi
549
590
  * finish it arrive, which is the only correct way to turn a byte stream into
550
591
  * text. String chunks bypass it: they are already decoded, and feeding them
551
592
  * through `Buffer.from` would re-encode text the caller never asked us to touch.
593
+ *
594
+ * The envelope type is a parameter (defaulting to this package's own wire type)
595
+ * purely so sibling newline-framed sockets can REUSE this decoder rather than
596
+ * re-deriving it. `@adhdev/terminal-mux-control`'s control socket carried a
597
+ * verbatim `chunk.toString()` copy of the pre-fix parser and inherited the same
598
+ * silent corruption; it now shares this implementation. Nothing about the
599
+ * framing is session-host-specific — only the payload type was, and that is now
600
+ * the caller's to name.
552
601
  */
553
- declare function createLineParser(onEnvelope: (envelope: SessionHostWireEnvelope) => void): {
602
+ declare function createLineParser<TEnvelope = SessionHostWireEnvelope>(onEnvelope: (envelope: TEnvelope) => void): {
554
603
  (chunk: Buffer | string): void;
555
604
  end(): string;
556
605
  };
@@ -700,8 +749,16 @@ declare function applyTerminalColorEnv(env: Record<string, string>): void;
700
749
  */
701
750
  declare function ensureNodePtySpawnHelperPermissions(logFn?: (msg: string) => void): void;
702
751
 
752
+ /** Response shape of the `get_snapshot` wire request — a PTY snapshot for a session. */
753
+ interface SessionHostSnapshot {
754
+ seq: number;
755
+ text: string;
756
+ truncated: boolean;
757
+ cols?: number;
758
+ rows?: number;
759
+ }
703
760
  /**
704
- * The 11-method session-host control-plane surface shared by the cloud and
761
+ * The session-host control-plane surface shared by the cloud and
705
762
  * standalone daemons. Both daemons used to carry a byte-for-byte copy of this
706
763
  * dispatch table plus the identical throw strings; this is the single source of
707
764
  * truth for that mapping. The wire `type` strings and error text here are the
@@ -721,6 +778,14 @@ interface SessionHostControlPlane {
721
778
  pruneDuplicateSessions(payload?: PruneDuplicateSessionsPayload): Promise<SessionHostPruneDuplicatesResult>;
722
779
  acquireWrite(payload: AcquireWritePayload): Promise<SessionHostRecord | null>;
723
780
  releaseWrite(payload: ReleaseWritePayload): Promise<SessionHostRecord | null>;
781
+ /**
782
+ * A PTY snapshot for `sessionId` (optionally only the tail since `sinceSeq`),
783
+ * over wire type `get_snapshot`. Mirrors the shape daemon-core's local
784
+ * `SessionHostControlPlane` interface (`commands/router.ts`) and cloud's
785
+ * former `cloud-command-transports.ts` P2P-only `get_runtime_snapshot` case
786
+ * both already used against a raw `SessionHostClient`.
787
+ */
788
+ getSnapshot(sessionId: string, sinceSeq?: number): Promise<SessionHostSnapshot | null>;
724
789
  }
725
790
  /**
726
791
  * Minimal request transport the control-plane factory needs. Each daemon injects
@@ -737,11 +802,11 @@ interface SessionHostControlTransport {
737
802
  request<T>(type: SessionHostRequestType, payload: Record<string, unknown>): Promise<T>;
738
803
  }
739
804
  /**
740
- * Build the shared 11-method control-plane over an injected request transport.
805
+ * Build the shared 12-method control-plane over an injected request transport.
741
806
  * The (type string, payload shape) pairs are verbatim what both daemons emitted
742
807
  * previously — do not reword them without matching the session-host daemon's
743
808
  * request handlers.
744
809
  */
745
810
  declare function createSessionHostControlPlane(transport: SessionHostControlTransport): SessionHostControlPlane;
746
811
 
747
- export { type AcquireWritePayload, type AttachSessionPayload, type ClearSessionBufferPayload, type CreateSessionPayload, DEFAULT_CONFIG_DIR_NAME, DEFAULT_SESSION_RING_BUFFER_MAX_BYTES, type DetachSessionPayload, type ForceDetachClientPayload, type GetHostDiagnosticsPayload, type GetSnapshotPayload, type GetTerminalSnapshotPayload, type PruneDuplicateSessionsPayload, type ReleaseWritePayload, type ResizeSessionPayload, type RestartSessionPayload, type ResumeSessionPayload, SESSION_HOST_SUPPORTED_REQUEST_TYPES, type SendInputPayload, type SendSignalPayload, type SessionAttachedClient, type SessionBufferSnapshot, type SessionClientType, type SessionHostCategory, SessionHostClient, type SessionHostClientOptions, type SessionHostControlPlane, type SessionHostControlTransport, type SessionHostDiagnostics, type SessionHostDisconnectInfo, type SessionHostDisconnectReason, type SessionHostDuplicateSessionGroup, type SessionHostEndpoint, type SessionHostEndpointOptions, type SessionHostEvent, type SessionHostEventEnvelope, type SessionHostLogEntry, type SessionHostPruneDuplicatesResult, type SessionHostRecord, SessionHostRegistry, type SessionHostRequest, type SessionHostRequestEnvelope, type SessionHostRequestTrace, type SessionHostRequestType, type SessionHostResponse, type SessionHostResponseEnvelope, type SessionHostRuntimeTransition, type SessionHostSurfaceKind, type SessionHostSurfaceRecordLike, type SessionHostWireEnvelope, type SessionLaunchCommand, type SessionLifecycle, type SessionOwnerType, SessionRingBuffer, type SessionRingBufferOptions, type SessionTerminalSnapshot, type SessionTerminalState, type SessionTermination, type SessionTransport, type SessionWriteOwner, type StopSessionPayload, type UpdateSessionMetaPayload, applyTerminalColorEnv, buildRuntimeDisplayName, buildRuntimeKey, canonicalizeInstancePath, classifyTermination, createLineParser, createResponseEnvelope, createSessionHostControlPlane, ensureNodePtySpawnHelperPermissions, formatRuntimeOwner, getDefaultSessionHostEndpoint, getSessionHostRecoveryLabel, getSessionHostSurfaceKind, getWorkspaceLabel, isDefaultInstanceConfigDir, isSessionHostLiveRuntime, isSessionHostRecoverySnapshot, isTerminalRecord, partitionSessionHostDiagnosticsSessions, partitionSessionHostRecords, resolveAttachableRuntimeRecord, resolveInstanceConfigDir, resolveRuntimeRecord, resolveSessionHostIpcKey, sanitizeSpawnEnv, writeEnvelope };
812
+ export { type AcquireWritePayload, type AttachSessionPayload, type ClearSessionBufferPayload, type CreateSessionPayload, DEFAULT_CONFIG_DIR_NAME, DEFAULT_SESSION_RING_BUFFER_MAX_BYTES, type DetachSessionPayload, type ForceDetachClientPayload, type GetHostDiagnosticsPayload, type GetSnapshotPayload, type GetTerminalSnapshotPayload, type PruneDuplicateSessionsPayload, type ReleaseWritePayload, type ResizeSessionPayload, type RestartSessionPayload, type ResumeSessionPayload, SESSION_HOST_SUPPORTED_REQUEST_TYPES, type SendInputPayload, type SendSignalPayload, type SessionAttachedClient, type SessionBufferSnapshot, type SessionClientType, type SessionHostCategory, SessionHostClient, type SessionHostClientOptions, type SessionHostControlPlane, type SessionHostControlTransport, type SessionHostDiagnostics, type SessionHostDisconnectInfo, type SessionHostDisconnectReason, type SessionHostDuplicateSessionGroup, type SessionHostEndpoint, type SessionHostEndpointOptions, type SessionHostEvent, type SessionHostEventEnvelope, type SessionHostLogEntry, type SessionHostPruneDuplicatesResult, type SessionHostRecord, SessionHostRegistry, type SessionHostRequest, type SessionHostRequestEnvelope, type SessionHostRequestTrace, type SessionHostRequestType, type SessionHostResponse, type SessionHostResponseEnvelope, type SessionHostRuntimeTransition, type SessionHostSnapshot, type SessionHostSurfaceKind, type SessionHostSurfaceRecordLike, type SessionHostWireEnvelope, type SessionLaunchCommand, type SessionLifecycle, type SessionOwnerType, SessionRingBuffer, type SessionRingBufferOptions, type SessionTerminalSnapshot, type SessionTerminalState, type SessionTermination, type SessionTransport, type SessionWriteOwner, type StopSessionPayload, type UpdateSessionMetaPayload, applyTerminalColorEnv, buildRuntimeDisplayName, buildRuntimeKey, canonicalizeInstancePath, classifyTermination, createLineParser, createResponseEnvelope, createSessionHostControlPlane, ensureNodePtySpawnHelperPermissions, formatRuntimeOwner, getDefaultSessionHostEndpoint, getSessionHostRecoveryLabel, getSessionHostSurfaceKind, getWorkspaceLabel, isDefaultInstanceConfigDir, isSessionHostLiveRuntime, isSessionHostRecoverySnapshot, isTerminalRecord, partitionSessionHostDiagnosticsSessions, partitionSessionHostRecords, resolveAttachableRuntimeRecord, resolveInstanceConfigDir, resolveRuntimeRecord, resolveSessionHostIpcKey, sanitizeSpawnEnv, writeEnvelope };
package/dist/index.d.ts CHANGED
@@ -456,6 +456,47 @@ declare class SessionRingBuffer {
456
456
  text: string;
457
457
  }): void;
458
458
  private trim;
459
+ /**
460
+ * TRIM-BOUNDARY repair. Eviction drops whole chunks, and a chunk boundary is
461
+ * a PTY read boundary — an arbitrary byte offset with no relationship to the
462
+ * structure of the stream. So the chunk that becomes the new oldest can begin
463
+ * partway through something the sender wrote atomically, and `snapshot()`
464
+ * joins from exactly there.
465
+ *
466
+ * The consumer is the browser terminal: "Load older terminal output" asks
467
+ * with `sinceSeq: 0`, which makes the daemon skip the emulator viewport and
468
+ * hand this raw text straight to xterm (see `mergeRuntimeSnapshot`). xterm
469
+ * then parses a stream that starts mid-token, which is how the reported
470
+ * screenshot got orphaned `.` and `5` glyphs floating above the output and a
471
+ * large blank band at the top:
472
+ *
473
+ * drop 5 chars -> "[HClaude Code v2.1.220…" the CSI introducer is gone,
474
+ * so `[H` prints literally
475
+ * drop 12 chars -> "2mClaude Code v2.1.220…" half an SGR prints literally
476
+ * drop 46 chars -> the leading `\x1b[2J\x1b[H` never arrives, so the screen
477
+ * is never cleared/homed and row placement collapses
478
+ * byte cut -> a torn 3-byte Hangul sequence decodes to U+FFFD
479
+ *
480
+ * So walk the head of the new oldest chunk forward to the first offset that
481
+ * is safe to start parsing at, and drop the partial prefix. Losing a few
482
+ * bytes of already-evicted context is strictly better than injecting literal
483
+ * garbage into the viewport.
484
+ *
485
+ * Two independent boundary classes have to be handled, and neither subsumes
486
+ * the other:
487
+ *
488
+ * 1. Character encoding. Chunks are JS strings, so a torn multi-byte UTF-8
489
+ * sequence has already decayed into U+FFFD (or, for astral characters, a
490
+ * lone surrogate) by the time it gets here. This mirrors the protection
491
+ * `createLineParser` grew for the IPC socket path in
492
+ * `ipc-line-parser-utf8.test.ts`; that layer can hold bytes back and
493
+ * re-join them because it owns both sides of the split, whereas here the
494
+ * other half is already gone, so dropping is the only repair available.
495
+ * 2. Escape sequences. A CSI/OSC/SS3 can be cut anywhere, and unlike the
496
+ * encoding case the leftover bytes are all perfectly printable — which is
497
+ * precisely why the corruption is visible rather than silent.
498
+ */
499
+ private healHead;
459
500
  }
460
501
 
461
502
  declare class SessionHostRegistry {
@@ -549,8 +590,16 @@ declare function getDefaultSessionHostEndpoint(appName?: string, options?: Sessi
549
590
  * finish it arrive, which is the only correct way to turn a byte stream into
550
591
  * text. String chunks bypass it: they are already decoded, and feeding them
551
592
  * through `Buffer.from` would re-encode text the caller never asked us to touch.
593
+ *
594
+ * The envelope type is a parameter (defaulting to this package's own wire type)
595
+ * purely so sibling newline-framed sockets can REUSE this decoder rather than
596
+ * re-deriving it. `@adhdev/terminal-mux-control`'s control socket carried a
597
+ * verbatim `chunk.toString()` copy of the pre-fix parser and inherited the same
598
+ * silent corruption; it now shares this implementation. Nothing about the
599
+ * framing is session-host-specific — only the payload type was, and that is now
600
+ * the caller's to name.
552
601
  */
553
- declare function createLineParser(onEnvelope: (envelope: SessionHostWireEnvelope) => void): {
602
+ declare function createLineParser<TEnvelope = SessionHostWireEnvelope>(onEnvelope: (envelope: TEnvelope) => void): {
554
603
  (chunk: Buffer | string): void;
555
604
  end(): string;
556
605
  };
@@ -700,8 +749,16 @@ declare function applyTerminalColorEnv(env: Record<string, string>): void;
700
749
  */
701
750
  declare function ensureNodePtySpawnHelperPermissions(logFn?: (msg: string) => void): void;
702
751
 
752
+ /** Response shape of the `get_snapshot` wire request — a PTY snapshot for a session. */
753
+ interface SessionHostSnapshot {
754
+ seq: number;
755
+ text: string;
756
+ truncated: boolean;
757
+ cols?: number;
758
+ rows?: number;
759
+ }
703
760
  /**
704
- * The 11-method session-host control-plane surface shared by the cloud and
761
+ * The session-host control-plane surface shared by the cloud and
705
762
  * standalone daemons. Both daemons used to carry a byte-for-byte copy of this
706
763
  * dispatch table plus the identical throw strings; this is the single source of
707
764
  * truth for that mapping. The wire `type` strings and error text here are the
@@ -721,6 +778,14 @@ interface SessionHostControlPlane {
721
778
  pruneDuplicateSessions(payload?: PruneDuplicateSessionsPayload): Promise<SessionHostPruneDuplicatesResult>;
722
779
  acquireWrite(payload: AcquireWritePayload): Promise<SessionHostRecord | null>;
723
780
  releaseWrite(payload: ReleaseWritePayload): Promise<SessionHostRecord | null>;
781
+ /**
782
+ * A PTY snapshot for `sessionId` (optionally only the tail since `sinceSeq`),
783
+ * over wire type `get_snapshot`. Mirrors the shape daemon-core's local
784
+ * `SessionHostControlPlane` interface (`commands/router.ts`) and cloud's
785
+ * former `cloud-command-transports.ts` P2P-only `get_runtime_snapshot` case
786
+ * both already used against a raw `SessionHostClient`.
787
+ */
788
+ getSnapshot(sessionId: string, sinceSeq?: number): Promise<SessionHostSnapshot | null>;
724
789
  }
725
790
  /**
726
791
  * Minimal request transport the control-plane factory needs. Each daemon injects
@@ -737,11 +802,11 @@ interface SessionHostControlTransport {
737
802
  request<T>(type: SessionHostRequestType, payload: Record<string, unknown>): Promise<T>;
738
803
  }
739
804
  /**
740
- * Build the shared 11-method control-plane over an injected request transport.
805
+ * Build the shared 12-method control-plane over an injected request transport.
741
806
  * The (type string, payload shape) pairs are verbatim what both daemons emitted
742
807
  * previously — do not reword them without matching the session-host daemon's
743
808
  * request handlers.
744
809
  */
745
810
  declare function createSessionHostControlPlane(transport: SessionHostControlTransport): SessionHostControlPlane;
746
811
 
747
- export { type AcquireWritePayload, type AttachSessionPayload, type ClearSessionBufferPayload, type CreateSessionPayload, DEFAULT_CONFIG_DIR_NAME, DEFAULT_SESSION_RING_BUFFER_MAX_BYTES, type DetachSessionPayload, type ForceDetachClientPayload, type GetHostDiagnosticsPayload, type GetSnapshotPayload, type GetTerminalSnapshotPayload, type PruneDuplicateSessionsPayload, type ReleaseWritePayload, type ResizeSessionPayload, type RestartSessionPayload, type ResumeSessionPayload, SESSION_HOST_SUPPORTED_REQUEST_TYPES, type SendInputPayload, type SendSignalPayload, type SessionAttachedClient, type SessionBufferSnapshot, type SessionClientType, type SessionHostCategory, SessionHostClient, type SessionHostClientOptions, type SessionHostControlPlane, type SessionHostControlTransport, type SessionHostDiagnostics, type SessionHostDisconnectInfo, type SessionHostDisconnectReason, type SessionHostDuplicateSessionGroup, type SessionHostEndpoint, type SessionHostEndpointOptions, type SessionHostEvent, type SessionHostEventEnvelope, type SessionHostLogEntry, type SessionHostPruneDuplicatesResult, type SessionHostRecord, SessionHostRegistry, type SessionHostRequest, type SessionHostRequestEnvelope, type SessionHostRequestTrace, type SessionHostRequestType, type SessionHostResponse, type SessionHostResponseEnvelope, type SessionHostRuntimeTransition, type SessionHostSurfaceKind, type SessionHostSurfaceRecordLike, type SessionHostWireEnvelope, type SessionLaunchCommand, type SessionLifecycle, type SessionOwnerType, SessionRingBuffer, type SessionRingBufferOptions, type SessionTerminalSnapshot, type SessionTerminalState, type SessionTermination, type SessionTransport, type SessionWriteOwner, type StopSessionPayload, type UpdateSessionMetaPayload, applyTerminalColorEnv, buildRuntimeDisplayName, buildRuntimeKey, canonicalizeInstancePath, classifyTermination, createLineParser, createResponseEnvelope, createSessionHostControlPlane, ensureNodePtySpawnHelperPermissions, formatRuntimeOwner, getDefaultSessionHostEndpoint, getSessionHostRecoveryLabel, getSessionHostSurfaceKind, getWorkspaceLabel, isDefaultInstanceConfigDir, isSessionHostLiveRuntime, isSessionHostRecoverySnapshot, isTerminalRecord, partitionSessionHostDiagnosticsSessions, partitionSessionHostRecords, resolveAttachableRuntimeRecord, resolveInstanceConfigDir, resolveRuntimeRecord, resolveSessionHostIpcKey, sanitizeSpawnEnv, writeEnvelope };
812
+ export { type AcquireWritePayload, type AttachSessionPayload, type ClearSessionBufferPayload, type CreateSessionPayload, DEFAULT_CONFIG_DIR_NAME, DEFAULT_SESSION_RING_BUFFER_MAX_BYTES, type DetachSessionPayload, type ForceDetachClientPayload, type GetHostDiagnosticsPayload, type GetSnapshotPayload, type GetTerminalSnapshotPayload, type PruneDuplicateSessionsPayload, type ReleaseWritePayload, type ResizeSessionPayload, type RestartSessionPayload, type ResumeSessionPayload, SESSION_HOST_SUPPORTED_REQUEST_TYPES, type SendInputPayload, type SendSignalPayload, type SessionAttachedClient, type SessionBufferSnapshot, type SessionClientType, type SessionHostCategory, SessionHostClient, type SessionHostClientOptions, type SessionHostControlPlane, type SessionHostControlTransport, type SessionHostDiagnostics, type SessionHostDisconnectInfo, type SessionHostDisconnectReason, type SessionHostDuplicateSessionGroup, type SessionHostEndpoint, type SessionHostEndpointOptions, type SessionHostEvent, type SessionHostEventEnvelope, type SessionHostLogEntry, type SessionHostPruneDuplicatesResult, type SessionHostRecord, SessionHostRegistry, type SessionHostRequest, type SessionHostRequestEnvelope, type SessionHostRequestTrace, type SessionHostRequestType, type SessionHostResponse, type SessionHostResponseEnvelope, type SessionHostRuntimeTransition, type SessionHostSnapshot, type SessionHostSurfaceKind, type SessionHostSurfaceRecordLike, type SessionHostWireEnvelope, type SessionLaunchCommand, type SessionLifecycle, type SessionOwnerType, SessionRingBuffer, type SessionRingBufferOptions, type SessionTerminalSnapshot, type SessionTerminalState, type SessionTermination, type SessionTransport, type SessionWriteOwner, type StopSessionPayload, type UpdateSessionMetaPayload, applyTerminalColorEnv, buildRuntimeDisplayName, buildRuntimeKey, canonicalizeInstancePath, classifyTermination, createLineParser, createResponseEnvelope, createSessionHostControlPlane, ensureNodePtySpawnHelperPermissions, formatRuntimeOwner, getDefaultSessionHostEndpoint, getSessionHostRecoveryLabel, getSessionHostSurfaceKind, getWorkspaceLabel, isDefaultInstanceConfigDir, isSessionHostLiveRuntime, isSessionHostRecoverySnapshot, isTerminalRecord, partitionSessionHostDiagnosticsSessions, partitionSessionHostRecords, resolveAttachableRuntimeRecord, resolveInstanceConfigDir, resolveRuntimeRecord, resolveSessionHostIpcKey, sanitizeSpawnEnv, writeEnvelope };
package/dist/index.js CHANGED
@@ -181,13 +181,112 @@ var SessionRingBuffer = class {
181
181
  this.trim();
182
182
  }
183
183
  trim() {
184
+ let evicted = false;
184
185
  while (this.totalBytes > this.maxBytes && this.chunks.length > 1) {
185
186
  const removed = this.chunks.shift();
186
187
  if (!removed) break;
187
188
  this.totalBytes -= removed.bytes;
189
+ evicted = true;
188
190
  }
191
+ if (evicted) this.healHead();
192
+ }
193
+ /**
194
+ * TRIM-BOUNDARY repair. Eviction drops whole chunks, and a chunk boundary is
195
+ * a PTY read boundary — an arbitrary byte offset with no relationship to the
196
+ * structure of the stream. So the chunk that becomes the new oldest can begin
197
+ * partway through something the sender wrote atomically, and `snapshot()`
198
+ * joins from exactly there.
199
+ *
200
+ * The consumer is the browser terminal: "Load older terminal output" asks
201
+ * with `sinceSeq: 0`, which makes the daemon skip the emulator viewport and
202
+ * hand this raw text straight to xterm (see `mergeRuntimeSnapshot`). xterm
203
+ * then parses a stream that starts mid-token, which is how the reported
204
+ * screenshot got orphaned `.` and `5` glyphs floating above the output and a
205
+ * large blank band at the top:
206
+ *
207
+ * drop 5 chars -> "[HClaude Code v2.1.220…" the CSI introducer is gone,
208
+ * so `[H` prints literally
209
+ * drop 12 chars -> "2mClaude Code v2.1.220…" half an SGR prints literally
210
+ * drop 46 chars -> the leading `\x1b[2J\x1b[H` never arrives, so the screen
211
+ * is never cleared/homed and row placement collapses
212
+ * byte cut -> a torn 3-byte Hangul sequence decodes to U+FFFD
213
+ *
214
+ * So walk the head of the new oldest chunk forward to the first offset that
215
+ * is safe to start parsing at, and drop the partial prefix. Losing a few
216
+ * bytes of already-evicted context is strictly better than injecting literal
217
+ * garbage into the viewport.
218
+ *
219
+ * Two independent boundary classes have to be handled, and neither subsumes
220
+ * the other:
221
+ *
222
+ * 1. Character encoding. Chunks are JS strings, so a torn multi-byte UTF-8
223
+ * sequence has already decayed into U+FFFD (or, for astral characters, a
224
+ * lone surrogate) by the time it gets here. This mirrors the protection
225
+ * `createLineParser` grew for the IPC socket path in
226
+ * `ipc-line-parser-utf8.test.ts`; that layer can hold bytes back and
227
+ * re-join them because it owns both sides of the split, whereas here the
228
+ * other half is already gone, so dropping is the only repair available.
229
+ * 2. Escape sequences. A CSI/OSC/SS3 can be cut anywhere, and unlike the
230
+ * encoding case the leftover bytes are all perfectly printable — which is
231
+ * precisely why the corruption is visible rather than silent.
232
+ */
233
+ healHead() {
234
+ const head = this.chunks[0];
235
+ if (!head) return;
236
+ const repaired = stripDanglingPrefix(head.data);
237
+ if (repaired === head.data) return;
238
+ const bytes = Buffer.byteLength(repaired, "utf8");
239
+ this.totalBytes -= head.bytes - bytes;
240
+ head.data = repaired;
241
+ head.bytes = bytes;
189
242
  }
190
243
  };
244
+ var CSI_FINAL = /[\x40-\x7e]/;
245
+ var CSI_COMMON_FINAL = /[ABCDEFGHJKSTLMPX@mnchlsurdfgqit]/;
246
+ function stripDanglingPrefix(text) {
247
+ let i = 0;
248
+ while (i < text.length) {
249
+ const code = text.charCodeAt(i);
250
+ if (code === 65533) {
251
+ i += 1;
252
+ continue;
253
+ }
254
+ if (code >= 55296 && code <= 56319) {
255
+ const next = text.charCodeAt(i + 1);
256
+ if (next >= 56320 && next <= 57343) break;
257
+ i += 1;
258
+ continue;
259
+ }
260
+ if (code >= 56320 && code <= 57343) {
261
+ i += 1;
262
+ continue;
263
+ }
264
+ break;
265
+ }
266
+ const rest = text.slice(i);
267
+ const danglingLength = danglingEscapeTailLength(rest);
268
+ return danglingLength > 0 ? rest.slice(danglingLength) : rest;
269
+ }
270
+ function danglingEscapeTailLength(text) {
271
+ if (!text || text.charCodeAt(0) === 27) return 0;
272
+ let i = 0;
273
+ const hasIntroducer = text[0] === "[" || text[0] === "]";
274
+ if (hasIntroducer) i = 1;
275
+ const start = i;
276
+ while (i < text.length && /[0-9;?:<=>]/.test(text[i])) i += 1;
277
+ if (i >= text.length) return 0;
278
+ if (text[0] === "]") {
279
+ const bel = text.indexOf("\x07");
280
+ if (bel >= 0) return bel + 1;
281
+ return 0;
282
+ }
283
+ if (!CSI_FINAL.test(text[i])) return 0;
284
+ if (!hasIntroducer) {
285
+ if (i === start) return 0;
286
+ if (!CSI_COMMON_FINAL.test(text[i])) return 0;
287
+ }
288
+ return i + 1;
289
+ }
191
290
 
192
291
  // src/defaults.ts
193
292
  var DEFAULT_SESSION_HOST_COLS = 80;
@@ -571,10 +670,21 @@ var SessionHostRegistry = class {
571
670
  writeOwner: record.writeOwner ? { ...record.writeOwner } : null,
572
671
  attachedClients: record.attachedClients.map((client) => ({ ...client })),
573
672
  buffer: { ...record.buffer },
574
- meta: { ...record.meta }
673
+ // Deep: meta carries nested records (e.g. the daemon's `launchRecord`,
674
+ // wiring-unification Phase E), and a caller mutating a returned record
675
+ // must never reach into the registry's own copy.
676
+ meta: cloneMeta(record.meta)
575
677
  };
576
678
  }
577
679
  };
680
+ function cloneMeta(meta) {
681
+ if (!meta) return {};
682
+ try {
683
+ return structuredClone(meta);
684
+ } catch {
685
+ return { ...meta };
686
+ }
687
+ }
578
688
 
579
689
  // src/ipc.ts
580
690
  var os = __toESM(require("os"));
@@ -960,6 +1070,9 @@ function createSessionHostControlPlane(transport) {
960
1070
  },
961
1071
  releaseWrite(payload) {
962
1072
  return transport.request("release_write", payload);
1073
+ },
1074
+ getSnapshot(sessionId, sinceSeq) {
1075
+ return transport.request("get_snapshot", { sessionId, sinceSeq });
963
1076
  }
964
1077
  };
965
1078
  }