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

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "1.0.60-rc.7",
3
+ "version": "1.0.60-rc.71",
4
4
  "description": "ADHDev local session host core — session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/buffer.ts CHANGED
@@ -75,10 +75,171 @@ export class SessionRingBuffer {
75
75
  }
76
76
 
77
77
  private trim(): void {
78
+ let evicted = false;
78
79
  while (this.totalBytes > this.maxBytes && this.chunks.length > 1) {
79
80
  const removed = this.chunks.shift();
80
81
  if (!removed) break;
81
82
  this.totalBytes -= removed.bytes;
83
+ evicted = true;
82
84
  }
85
+ if (evicted) this.healHead();
83
86
  }
87
+
88
+ /**
89
+ * TRIM-BOUNDARY repair. Eviction drops whole chunks, and a chunk boundary is
90
+ * a PTY read boundary — an arbitrary byte offset with no relationship to the
91
+ * structure of the stream. So the chunk that becomes the new oldest can begin
92
+ * partway through something the sender wrote atomically, and `snapshot()`
93
+ * joins from exactly there.
94
+ *
95
+ * The consumer is the browser terminal: "Load older terminal output" asks
96
+ * with `sinceSeq: 0`, which makes the daemon skip the emulator viewport and
97
+ * hand this raw text straight to xterm (see `mergeRuntimeSnapshot`). xterm
98
+ * then parses a stream that starts mid-token, which is how the reported
99
+ * screenshot got orphaned `.` and `5` glyphs floating above the output and a
100
+ * large blank band at the top:
101
+ *
102
+ * drop 5 chars -> "[HClaude Code v2.1.220…" the CSI introducer is gone,
103
+ * so `[H` prints literally
104
+ * drop 12 chars -> "2mClaude Code v2.1.220…" half an SGR prints literally
105
+ * drop 46 chars -> the leading `\x1b[2J\x1b[H` never arrives, so the screen
106
+ * is never cleared/homed and row placement collapses
107
+ * byte cut -> a torn 3-byte Hangul sequence decodes to U+FFFD
108
+ *
109
+ * So walk the head of the new oldest chunk forward to the first offset that
110
+ * is safe to start parsing at, and drop the partial prefix. Losing a few
111
+ * bytes of already-evicted context is strictly better than injecting literal
112
+ * garbage into the viewport.
113
+ *
114
+ * Two independent boundary classes have to be handled, and neither subsumes
115
+ * the other:
116
+ *
117
+ * 1. Character encoding. Chunks are JS strings, so a torn multi-byte UTF-8
118
+ * sequence has already decayed into U+FFFD (or, for astral characters, a
119
+ * lone surrogate) by the time it gets here. This mirrors the protection
120
+ * `createLineParser` grew for the IPC socket path in
121
+ * `ipc-line-parser-utf8.test.ts`; that layer can hold bytes back and
122
+ * re-join them because it owns both sides of the split, whereas here the
123
+ * other half is already gone, so dropping is the only repair available.
124
+ * 2. Escape sequences. A CSI/OSC/SS3 can be cut anywhere, and unlike the
125
+ * encoding case the leftover bytes are all perfectly printable — which is
126
+ * precisely why the corruption is visible rather than silent.
127
+ */
128
+ private healHead(): void {
129
+ const head = this.chunks[0];
130
+ if (!head) return;
131
+
132
+ const repaired = stripDanglingPrefix(head.data);
133
+ if (repaired === head.data) return;
134
+
135
+ const bytes = Buffer.byteLength(repaired, 'utf8');
136
+ this.totalBytes -= head.bytes - bytes;
137
+ head.data = repaired;
138
+ head.bytes = bytes;
139
+ }
140
+ }
141
+
142
+ /** Final byte of a CSI (`ESC [ … X`) or SS3 sequence. */
143
+ const CSI_FINAL = /[\x40-\x7e]/;
144
+
145
+ /**
146
+ * The subset of CSI final bytes terminals actually emit: cursor movement
147
+ * (ABCDEFGHfd), erase (JK), scroll (STLM), insert/delete (PX@), SGR (m),
148
+ * device status (nc), mode set/reset (hl), save/restore cursor (su) and
149
+ * scroll region (r). Used only when the `[` introducer was itself evicted, so
150
+ * that a digit run followed by an arbitrary letter is not mistaken for a
151
+ * sequence — the whole CSI final range \x40-\x7e includes most of the
152
+ * alphabet and would swallow ordinary prose.
153
+ */
154
+ const CSI_COMMON_FINAL = /[ABCDEFGHJKSTLMPX@mnchlsurdfgqit]/;
155
+
156
+ /**
157
+ * Returns `text` with any leading fragment of a torn character or escape
158
+ * sequence removed. Returns `text` unchanged when the head is already a safe
159
+ * place to start parsing.
160
+ */
161
+ function stripDanglingPrefix(text: string): string {
162
+ let i = 0;
163
+
164
+ // 1. Encoding damage. A torn multi-byte sequence survives as U+FFFD or as an
165
+ // unpaired surrogate; both are meaningless on their own.
166
+ while (i < text.length) {
167
+ const code = text.charCodeAt(i);
168
+ if (code === 0xfffd) { i += 1; continue; }
169
+ // High surrogate followed by a low surrogate is a complete astral
170
+ // character — keep it. Either half alone is debris.
171
+ if (code >= 0xd800 && code <= 0xdbff) {
172
+ const next = text.charCodeAt(i + 1);
173
+ if (next >= 0xdc00 && next <= 0xdfff) break;
174
+ i += 1;
175
+ continue;
176
+ }
177
+ if (code >= 0xdc00 && code <= 0xdfff) { i += 1; continue; }
178
+ break;
179
+ }
180
+
181
+ // 2. Escape-sequence damage. Only a *leading* fragment can be torn: anything
182
+ // at or after the first ESC still has its introducer, so it will parse.
183
+ // Scan to the first ESC (or to a control character that resynchronises the
184
+ // parser anyway) and drop everything before it — but only if what precedes
185
+ // it actually looks like the tail of a sequence rather than ordinary text,
186
+ // so a buffer that legitimately starts mid-line is left alone.
187
+ const rest = text.slice(i);
188
+ const danglingLength = danglingEscapeTailLength(rest);
189
+ return danglingLength > 0 ? rest.slice(danglingLength) : rest;
190
+ }
191
+
192
+ /**
193
+ * Length of the leading run that is the tail of a cut escape sequence, or 0
194
+ * when the text starts cleanly.
195
+ *
196
+ * A cut CSI leaves one of:
197
+ * `[2J…` `2J…` `J…` (introducer and/or parameters lost)
198
+ * and a cut OSC leaves parameter/payload text terminated by BEL or ST. What
199
+ * they have in common is that the *remaining* prefix is a run of parameter
200
+ * bytes ending at a final byte, all of it before the next ESC. Ordinary output
201
+ * only matches that shape when it happens to consist solely of parameter
202
+ * characters, so the scan stops at the first character that cannot appear in a
203
+ * sequence — a letter mid-word, a space, a newline — and reports 0.
204
+ */
205
+ function danglingEscapeTailLength(text: string): number {
206
+ if (!text || text.charCodeAt(0) === 0x1b) return 0;
207
+
208
+ let i = 0;
209
+ // An orphaned `[` is the most common shape (the ESC alone was evicted).
210
+ const hasIntroducer = text[0] === '[' || text[0] === ']';
211
+ if (hasIntroducer) i = 1;
212
+
213
+ const start = i;
214
+ // Parameter bytes only: digits, `;`, `?`, `:` and the private markers.
215
+ // Deliberately NOT the intermediate bytes (space, `!`, `"`, `$`, `'`):
216
+ // including space made ordinary prose match — "2 files changed" scans `2`,
217
+ // ` `, then treats `f` as a CSI final and eats "2 f". Sequences that use
218
+ // intermediates are rare enough that leaving their tail in place is far
219
+ // cheaper than truncating real output.
220
+ while (i < text.length && /[0-9;?:<=>]/.test(text[i])) i += 1;
221
+
222
+ if (i >= text.length) return 0;
223
+
224
+ // OSC tail: ends at BEL or ST rather than a CSI final byte.
225
+ if (text[0] === ']') {
226
+ const bel = text.indexOf('\x07');
227
+ if (bel >= 0) return bel + 1;
228
+ return 0;
229
+ }
230
+
231
+ if (!CSI_FINAL.test(text[i])) return 0;
232
+
233
+ // Require evidence of an actual sequence rather than a coincidence. With the
234
+ // orphaned `[` present the shape is already unambiguous. Without it, demand
235
+ // at least one parameter byte followed by one of the final bytes terminals
236
+ // actually emit — `32m` and `2J` are sequences; "J is a letter" has no
237
+ // parameter byte, and "2 files changed" ends its digit run at a space, which
238
+ // is not in the parameter class at all.
239
+ if (!hasIntroducer) {
240
+ if (i === start) return 0;
241
+ if (!CSI_COMMON_FINAL.test(text[i])) return 0;
242
+ }
243
+
244
+ return i + 1;
84
245
  }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AcquireWritePayload,
3
3
  GetHostDiagnosticsPayload,
4
+ GetSnapshotPayload,
4
5
  PruneDuplicateSessionsPayload,
5
6
  ReleaseWritePayload,
6
7
  SessionHostDiagnostics,
@@ -9,8 +10,17 @@ import type {
9
10
  SessionHostRequestType,
10
11
  } from './types.js';
11
12
 
13
+ /** Response shape of the `get_snapshot` wire request — a PTY snapshot for a session. */
14
+ export interface SessionHostSnapshot {
15
+ seq: number;
16
+ text: string;
17
+ truncated: boolean;
18
+ cols?: number;
19
+ rows?: number;
20
+ }
21
+
12
22
  /**
13
- * The 11-method session-host control-plane surface shared by the cloud and
23
+ * The session-host control-plane surface shared by the cloud and
14
24
  * standalone daemons. Both daemons used to carry a byte-for-byte copy of this
15
25
  * dispatch table plus the identical throw strings; this is the single source of
16
26
  * truth for that mapping. The wire `type` strings and error text here are the
@@ -28,6 +38,14 @@ export interface SessionHostControlPlane {
28
38
  pruneDuplicateSessions(payload?: PruneDuplicateSessionsPayload): Promise<SessionHostPruneDuplicatesResult>;
29
39
  acquireWrite(payload: AcquireWritePayload): Promise<SessionHostRecord | null>;
30
40
  releaseWrite(payload: ReleaseWritePayload): Promise<SessionHostRecord | null>;
41
+ /**
42
+ * A PTY snapshot for `sessionId` (optionally only the tail since `sinceSeq`),
43
+ * over wire type `get_snapshot`. Mirrors the shape daemon-core's local
44
+ * `SessionHostControlPlane` interface (`commands/router.ts`) and cloud's
45
+ * former `cloud-command-transports.ts` P2P-only `get_runtime_snapshot` case
46
+ * both already used against a raw `SessionHostClient`.
47
+ */
48
+ getSnapshot(sessionId: string, sinceSeq?: number): Promise<SessionHostSnapshot | null>;
31
49
  }
32
50
 
33
51
  /**
@@ -46,7 +64,7 @@ export interface SessionHostControlTransport {
46
64
  }
47
65
 
48
66
  /**
49
- * Build the shared 11-method control-plane over an injected request transport.
67
+ * Build the shared 12-method control-plane over an injected request transport.
50
68
  * The (type string, payload shape) pairs are verbatim what both daemons emitted
51
69
  * previously — do not reword them without matching the session-host daemon's
52
70
  * request handlers.
@@ -88,5 +106,8 @@ export function createSessionHostControlPlane(
88
106
  releaseWrite(payload: ReleaseWritePayload): Promise<SessionHostRecord | null> {
89
107
  return transport.request<SessionHostRecord | null>('release_write', payload as unknown as Record<string, unknown>);
90
108
  },
109
+ getSnapshot(sessionId: string, sinceSeq?: number): Promise<SessionHostSnapshot | null> {
110
+ return transport.request<SessionHostSnapshot | null>('get_snapshot', { sessionId, sinceSeq } as GetSnapshotPayload as unknown as Record<string, unknown>);
111
+ },
91
112
  };
92
113
  }
package/src/index.ts CHANGED
@@ -102,4 +102,4 @@ export {
102
102
  ensureNodePtySpawnHelperPermissions,
103
103
  } from './spawn-env.js';
104
104
  export { createSessionHostControlPlane } from './control-plane.js';
105
- export type { SessionHostControlPlane, SessionHostControlTransport } from './control-plane.js';
105
+ export type { SessionHostControlPlane, SessionHostControlTransport, SessionHostSnapshot } from './control-plane.js';
package/src/ipc.ts CHANGED
@@ -83,8 +83,18 @@ function serializeEnvelope(envelope: SessionHostWireEnvelope): string {
83
83
  * finish it arrive, which is the only correct way to turn a byte stream into
84
84
  * text. String chunks bypass it: they are already decoded, and feeding them
85
85
  * through `Buffer.from` would re-encode text the caller never asked us to touch.
86
+ *
87
+ * The envelope type is a parameter (defaulting to this package's own wire type)
88
+ * purely so sibling newline-framed sockets can REUSE this decoder rather than
89
+ * re-deriving it. `@adhdev/terminal-mux-control`'s control socket carried a
90
+ * verbatim `chunk.toString()` copy of the pre-fix parser and inherited the same
91
+ * silent corruption; it now shares this implementation. Nothing about the
92
+ * framing is session-host-specific — only the payload type was, and that is now
93
+ * the caller's to name.
86
94
  */
87
- function createLineParser(onEnvelope: (envelope: SessionHostWireEnvelope) => void) {
95
+ function createLineParser<TEnvelope = SessionHostWireEnvelope>(
96
+ onEnvelope: (envelope: TEnvelope) => void,
97
+ ) {
88
98
  let buffer = '';
89
99
  const decoder = new StringDecoder('utf8');
90
100
  const parser = (chunk: Buffer | string) => {
@@ -94,7 +104,7 @@ function createLineParser(onEnvelope: (envelope: SessionHostWireEnvelope) => voi
94
104
  const rawLine = buffer.slice(0, newlineIndex).trim();
95
105
  buffer = buffer.slice(newlineIndex + 1);
96
106
  if (rawLine) {
97
- onEnvelope(JSON.parse(rawLine) as SessionHostWireEnvelope);
107
+ onEnvelope(JSON.parse(rawLine) as TEnvelope);
98
108
  }
99
109
  newlineIndex = buffer.indexOf('\n');
100
110
  }
package/src/registry.ts CHANGED
@@ -274,7 +274,20 @@ export class SessionHostRegistry {
274
274
  writeOwner: record.writeOwner ? { ...record.writeOwner } : null,
275
275
  attachedClients: record.attachedClients.map(client => ({ ...client })),
276
276
  buffer: { ...record.buffer },
277
- meta: { ...record.meta },
277
+ // Deep: meta carries nested records (e.g. the daemon's `launchRecord`,
278
+ // wiring-unification Phase E), and a caller mutating a returned record
279
+ // must never reach into the registry's own copy.
280
+ meta: cloneMeta(record.meta),
278
281
  };
279
282
  }
280
283
  }
284
+
285
+ function cloneMeta(meta: Record<string, unknown> | undefined): Record<string, unknown> {
286
+ if (!meta) return {};
287
+ try {
288
+ return structuredClone(meta);
289
+ } catch {
290
+ // A non-cloneable value (function, class instance) — keep the old shallow copy.
291
+ return { ...meta };
292
+ }
293
+ }