@harness-nexus/cli 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
Files changed (68) hide show
  1. package/dist/daemon/acp/adapters.d.ts.map +1 -1
  2. package/dist/daemon/acp/adapters.js +9 -3
  3. package/dist/daemon/acp/adapters.js.map +1 -1
  4. package/dist/daemon/acp/agent-connection.d.ts +67 -4
  5. package/dist/daemon/acp/agent-connection.d.ts.map +1 -1
  6. package/dist/daemon/acp/agent-connection.js +113 -9
  7. package/dist/daemon/acp/agent-connection.js.map +1 -1
  8. package/dist/daemon/adapter-ledger.d.ts +62 -0
  9. package/dist/daemon/adapter-ledger.d.ts.map +1 -0
  10. package/dist/daemon/adapter-ledger.js +207 -0
  11. package/dist/daemon/adapter-ledger.js.map +1 -0
  12. package/dist/daemon/chat.d.ts +33 -2
  13. package/dist/daemon/chat.d.ts.map +1 -1
  14. package/dist/daemon/chat.js +956 -42
  15. package/dist/daemon/chat.js.map +1 -1
  16. package/dist/daemon/client.d.ts +11 -2
  17. package/dist/daemon/client.d.ts.map +1 -1
  18. package/dist/daemon/client.js +122 -24
  19. package/dist/daemon/client.js.map +1 -1
  20. package/dist/daemon/config-view.d.ts +42 -0
  21. package/dist/daemon/config-view.d.ts.map +1 -0
  22. package/dist/daemon/config-view.js +159 -0
  23. package/dist/daemon/config-view.js.map +1 -0
  24. package/dist/daemon/dsh-sessions.d.ts +161 -0
  25. package/dist/daemon/dsh-sessions.d.ts.map +1 -0
  26. package/dist/daemon/dsh-sessions.js +674 -0
  27. package/dist/daemon/dsh-sessions.js.map +1 -0
  28. package/dist/daemon/dsh-tap/index.mjs +99 -0
  29. package/dist/daemon/dsh-tap-listener.d.ts +62 -0
  30. package/dist/daemon/dsh-tap-listener.d.ts.map +1 -0
  31. package/dist/daemon/dsh-tap-listener.js +196 -0
  32. package/dist/daemon/dsh-tap-listener.js.map +1 -0
  33. package/dist/daemon/jobs.d.ts +8 -5
  34. package/dist/daemon/jobs.d.ts.map +1 -1
  35. package/dist/daemon/jobs.js +28 -8
  36. package/dist/daemon/jobs.js.map +1 -1
  37. package/dist/daemon/runtime-config.d.ts +34 -0
  38. package/dist/daemon/runtime-config.d.ts.map +1 -0
  39. package/dist/daemon/runtime-config.js +347 -0
  40. package/dist/daemon/runtime-config.js.map +1 -0
  41. package/dist/daemon/runtime.d.ts +49 -0
  42. package/dist/daemon/runtime.d.ts.map +1 -0
  43. package/dist/daemon/runtime.js +218 -0
  44. package/dist/daemon/runtime.js.map +1 -0
  45. package/dist/daemon/sessions.d.ts +26 -0
  46. package/dist/daemon/sessions.d.ts.map +1 -0
  47. package/dist/daemon/sessions.js +169 -0
  48. package/dist/daemon/sessions.js.map +1 -0
  49. package/dist/daemon/workspace.d.ts +5 -0
  50. package/dist/daemon/workspace.d.ts.map +1 -0
  51. package/dist/daemon/workspace.js +44 -0
  52. package/dist/daemon/workspace.js.map +1 -0
  53. package/dist/install/adapters/deepseek.d.ts +4 -0
  54. package/dist/install/adapters/deepseek.d.ts.map +1 -1
  55. package/dist/install/adapters/deepseek.js +4 -2
  56. package/dist/install/adapters/deepseek.js.map +1 -1
  57. package/dist/inventory/common.d.ts +2 -0
  58. package/dist/inventory/common.d.ts.map +1 -1
  59. package/dist/inventory/common.js.map +1 -1
  60. package/dist/inventory/runtime.d.ts +40 -0
  61. package/dist/inventory/runtime.d.ts.map +1 -0
  62. package/dist/inventory/runtime.js +114 -0
  63. package/dist/inventory/runtime.js.map +1 -0
  64. package/dist/inventory/scanners/claude-code.d.ts +2 -0
  65. package/dist/inventory/scanners/claude-code.d.ts.map +1 -1
  66. package/dist/inventory/scanners/claude-code.js +103 -39
  67. package/dist/inventory/scanners/claude-code.js.map +1 -1
  68. package/package.json +6 -6
@@ -0,0 +1,674 @@
1
+ import * as zlib from 'node:zlib';
2
+ /**
3
+ * dsh native session store reader (Phase 9 W7).
4
+ * docs/design/phase-9-w7-native-sessions.md § ground truth.
5
+ *
6
+ * dsh persists every session at `~/.dsh/sessions/<cwd-slug>/<uuid>/
7
+ * session.jsonl.zstd` as MULTI-FRAME zstd — one frame per write batch (the
8
+ * engine appends every LLM chunk as a `assistant/chunk` session event; a
9
+ * bounded write-behind window coalesces them ~100–300ms apart). Node's
10
+ * one-shot AND stream zstd decoders stop after the first frame, so the reader
11
+ * scans the frame magic (`28 B5 2F FD`), slices, and decodes each frame
12
+ * separately. Delta runs reach disk in TWO shapes — packed `text-chunks`/
13
+ * `reasoning-chunks`/`tool-call-chunks` rows (runs of ≥3 seq-contiguous
14
+ * same-block deltas) and verbatim `assistant/chunk` events below that
15
+ * (MIN_RUN=3 fall-through; shapes verified against dsh 0.1.2-rc.1 source).
16
+ * Everything here is pure/bounded and injectable for tests; the zstd binding
17
+ * is feature-detected (needs Node ≥ 22.15 — machines running dsh already
18
+ * require it, but the daemon degrades gracefully below it).
19
+ *
20
+ * dsh's ACP adapter surfaces only COMMITTED updates — a whole reasoning/text
21
+ * block arrives as ONE chunk when the turn ends, so there is no streaming
22
+ * over the protocol. The transcript file, however, is appended LIVE with the
23
+ * token batches, which is what `TranscriptTail` turns into real-time deltas.
24
+ */
25
+ /** zstd frame magic, big-endian as stored. */
26
+ const ZSTD_MAGIC = 0x28_b5_2f_fd;
27
+ /** The native `zlib.zstdDecompressSync`, or null on Node < 22.15. */
28
+ export function nativeZstd() {
29
+ const fn = zlib.zstdDecompressSync;
30
+ return typeof fn === 'function' ? fn : null;
31
+ }
32
+ /** Slice a concatenated-zstd buffer into its frames (magic scan). */
33
+ export function splitZstdFrames(buf) {
34
+ const frames = [];
35
+ let start = -1;
36
+ for (let i = 0; i + 4 <= buf.length; i++) {
37
+ if (buf.readUInt32BE(i) === ZSTD_MAGIC) {
38
+ if (start !== -1)
39
+ frames.push(buf.subarray(start, i));
40
+ start = i;
41
+ i += 3;
42
+ }
43
+ }
44
+ if (start !== -1)
45
+ frames.push(buf.subarray(start));
46
+ return frames;
47
+ }
48
+ /**
49
+ * Decode every frame and parse the JSONL entries. Undecodable frames (a magic
50
+ * hit inside compressed data — ~2^-32 per position) and non-JSON lines are
51
+ * skipped, never fatal. Entries are capped so a runaway transcript can't
52
+ * exhaust memory.
53
+ */
54
+ export function decodeTranscript(buf, decompress, maxEntries = 20000) {
55
+ const entries = [];
56
+ for (const frame of splitZstdFrames(buf)) {
57
+ let text;
58
+ try {
59
+ text = decompress(frame).toString('utf8');
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ for (const line of text.split('\n')) {
65
+ if (line.trim() === '')
66
+ continue;
67
+ try {
68
+ const parsed = JSON.parse(line);
69
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
70
+ entries.push(parsed);
71
+ if (entries.length >= maxEntries)
72
+ return entries;
73
+ }
74
+ }
75
+ catch {
76
+ // non-JSON noise — skip
77
+ }
78
+ }
79
+ }
80
+ return entries;
81
+ }
82
+ /**
83
+ * Header + derived title + pinned model from the FRONT of a transcript
84
+ * (dsh's own `session/list` exposes none of these). Stops as soon as all are
85
+ * found; bounded read so a huge transcript costs at most `maxBytes`. NB: the
86
+ * `session` HEADER entry carries its fields at the TOP level (no `data`
87
+ * wrapper), unlike every other entry type.
88
+ */
89
+ export function scanSummary(buf, decompress, maxBytes = 64 * 1024) {
90
+ const bounded = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
91
+ let header = null;
92
+ let title = null;
93
+ let model = null;
94
+ // A real user turn (`agent/inbox/spliced`) — the marker that separates a
95
+ // LIVED-IN session from dsh's config-preamble-only files (every fresh
96
+ // session writes session/permission-preset/sandbox-mode/approval-policy
97
+ // rows before any conversation, so header presence is NOT enough).
98
+ let hasTurn = false;
99
+ for (const frame of splitZstdFrames(bounded)) {
100
+ let text;
101
+ try {
102
+ text = decompress(frame).toString('utf8');
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ for (const line of text.split('\n')) {
108
+ if (line.trim() === '')
109
+ continue;
110
+ try {
111
+ const e = JSON.parse(line);
112
+ if (e['type'] === 'session' && header === null) {
113
+ header = e; // top-level shape — see the doc comment
114
+ }
115
+ else if (e['type'] === 'session/title') {
116
+ const t = e['data']?.['title'];
117
+ if (typeof t === 'string' && t !== '')
118
+ title = t;
119
+ }
120
+ else if (e['type'] === 'request/header') {
121
+ const hdr = e['data']?.['header'];
122
+ const config = isRecord(hdr) ? hdr['config'] : undefined;
123
+ const m = isRecord(config) ? config['model'] : undefined;
124
+ if (typeof m === 'string' && m !== '')
125
+ model = m;
126
+ }
127
+ else if (e['type'] === 'agent/inbox/spliced') {
128
+ hasTurn = true;
129
+ }
130
+ }
131
+ catch {
132
+ // skip
133
+ }
134
+ }
135
+ if (header !== null && title !== null && model !== null && hasTurn)
136
+ break;
137
+ }
138
+ return { header, title, model, hasTurn };
139
+ }
140
+ /**
141
+ * The model ids of OUR managed provider region in `~/.dsh/settings.yaml`
142
+ * (the W3 writer's own format — a `models:` list of `- id:` entries). Null
143
+ * when the region is absent/unparseable: the caller then annotates nothing.
144
+ */
145
+ export function currentCatalogModels(settingsYaml) {
146
+ const region = settingsYaml.match(/# BEGIN harness-nexus \(managed\)([\s\S]*?)# END harness-nexus \(managed\)/);
147
+ if (region === null)
148
+ return null;
149
+ const ids = new Set();
150
+ for (const m of region[1].matchAll(/- id:\s*"?([^"\s\n]+)"?/g))
151
+ ids.add(m[1]);
152
+ return ids.size > 0 ? ids : null;
153
+ }
154
+ /**
155
+ * List dsh's ROOT sessions (subagent sessions carry `parentSession`/depth and
156
+ * are dsh's own resume-exclusions). Pure given the fs shims; newest first.
157
+ */
158
+ export function dshListSessions(sessionsDir, fs, decompress, opts = {}) {
159
+ const max = opts.maxSessions ?? 100;
160
+ const out = [];
161
+ let slugs;
162
+ try {
163
+ slugs = fs.readdir(sessionsDir);
164
+ }
165
+ catch {
166
+ return []; // no store yet — an honest empty listing
167
+ }
168
+ for (const slug of slugs) {
169
+ let ids;
170
+ try {
171
+ ids = fs.readdir(`${sessionsDir}/${slug}`);
172
+ }
173
+ catch {
174
+ continue;
175
+ }
176
+ for (const id of ids) {
177
+ if (out.length >= max)
178
+ break;
179
+ // Post-W8: sessions with a live channel are LISTED (no longer hidden) —
180
+ // the server marks them `open` and the rail offers a rejoin. dsh itself
181
+ // still refuses a fresh resume of an active session; that failure path
182
+ // is exactly why the row must rejoin the existing channel instead.
183
+ const file = `${sessionsDir}/${slug}/${id}/session.jsonl.zstd`;
184
+ let buf;
185
+ try {
186
+ buf = fs.readFile(file);
187
+ }
188
+ catch {
189
+ continue;
190
+ }
191
+ const { header, title, model, hasTurn } = scanSummary(buf, decompress);
192
+ // Header-only/config-preamble transcripts are NOT conversations: every
193
+ // dsh spawn writes them before any turn, and the -32605 startup-race
194
+ // retry leaves one behind per lost race (the "two unnamed sessions"
195
+ // double). Listing only lived-in sessions matches claude-code's
196
+ // transcript-on-first-message semantics; a live-but-still-empty channel
197
+ // still shows via the server's synthesized open row.
198
+ if (!hasTurn)
199
+ continue;
200
+ if (header === null)
201
+ continue;
202
+ if (typeof header.cwd !== 'string' || header.cwd === '' || !header.cwd.startsWith('/')) {
203
+ continue;
204
+ }
205
+ if (header.parentSession !== undefined || header.delegationDepth !== 0)
206
+ continue;
207
+ let mtimeMs;
208
+ try {
209
+ mtimeMs = fs.stat(file).mtimeMs;
210
+ }
211
+ catch {
212
+ mtimeMs = 0;
213
+ }
214
+ out.push({
215
+ sessionId: id,
216
+ cwd: header.cwd,
217
+ title,
218
+ model,
219
+ createdAt: typeof header.createdAt === 'number' && Number.isFinite(header.createdAt)
220
+ ? new Date(header.createdAt).toISOString()
221
+ : null,
222
+ updatedAt: mtimeMs > 0 ? new Date(mtimeMs).toISOString() : null,
223
+ });
224
+ }
225
+ }
226
+ const stamp = (s) => Date.parse(s.updatedAt ?? '') || Date.parse(s.createdAt ?? '') || 0;
227
+ out.sort((a, b) => stamp(b) - stamp(a) || b.sessionId.localeCompare(a.sessionId));
228
+ return out;
229
+ }
230
+ const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
231
+ const OUTPUT_MAX = 100_000;
232
+ function bounded(raw, max) {
233
+ if (typeof raw !== 'string' || raw === '')
234
+ return undefined;
235
+ return raw.length <= max ? raw : raw.slice(0, max);
236
+ }
237
+ /** Parse dsh's tool `arguments` (a JSON string) into a bounded rawInput map. */
238
+ function parseArguments(raw) {
239
+ if (typeof raw !== 'string' || raw === '')
240
+ return undefined;
241
+ try {
242
+ const parsed = JSON.parse(raw);
243
+ if (isRecord(parsed))
244
+ return parsed;
245
+ }
246
+ catch {
247
+ // malformed — the toolName/title still render
248
+ }
249
+ return undefined;
250
+ }
251
+ /** The `tool/call` shape — in_progress with parsed arguments (shared: history + live tail). */
252
+ function toolStartEvent(callId, name, args) {
253
+ if (typeof callId !== 'string' || callId === '')
254
+ return null;
255
+ return {
256
+ kind: 'tool_call',
257
+ call: {
258
+ toolCallId: callId,
259
+ ...(typeof name === 'string' && name !== '' ? { toolName: bounded(name, 128) } : {}),
260
+ status: 'in_progress',
261
+ ...(parseArguments(args) !== undefined
262
+ ? { rawInput: parseArguments(args) }
263
+ : {}),
264
+ },
265
+ };
266
+ }
267
+ /** The `tool/result` shape — completed with the joined output text (shared). */
268
+ function toolResultEvent(message) {
269
+ const source = message !== null && isRecord(message['source']) ? message['source'] : null;
270
+ const callId = source !== null ? source['callId'] : undefined;
271
+ if (typeof callId !== 'string' || callId === '')
272
+ return null;
273
+ const texts = [];
274
+ const content = message !== null ? message['content'] : undefined;
275
+ if (Array.isArray(content)) {
276
+ for (const c of content) {
277
+ if (!isRecord(c))
278
+ continue;
279
+ const inner = c['content'];
280
+ if (!Array.isArray(inner))
281
+ continue;
282
+ for (const piece of inner) {
283
+ if (isRecord(piece) && piece['type'] === 'text' && typeof piece['text'] === 'string') {
284
+ texts.push(piece['text']);
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return {
290
+ kind: 'tool_call',
291
+ call: {
292
+ toolCallId: callId,
293
+ status: 'completed',
294
+ ...(texts.length > 0 ? { output: bounded(texts.join('\n'), OUTPUT_MAX) } : {}),
295
+ },
296
+ };
297
+ }
298
+ /** The `turn:step` dedup key, or null when the entry carries no coordinates. */
299
+ function stepKey(data) {
300
+ const turn = data['turn'];
301
+ const step = data['step'];
302
+ return typeof turn === 'number' && typeof step === 'number' ? `${turn}:${step}` : null;
303
+ }
304
+ /** A usage event from a commit's `usage` payload (null when absent/malformed). */
305
+ function usageEvent(raw) {
306
+ if (!isRecord(raw))
307
+ return null;
308
+ const input = raw['inputTokens'];
309
+ const output = raw['outputTokens'];
310
+ if (typeof input !== 'number' && typeof output !== 'number')
311
+ return null;
312
+ return {
313
+ kind: 'usage',
314
+ ...(typeof input === 'number' ? { inputTokens: input } : {}),
315
+ ...(typeof output === 'number' ? { outputTokens: output } : {}),
316
+ };
317
+ }
318
+ /** The usage marker of a verbatim `assistant/chunk` row (history path). */
319
+ export function usageFromChunk(entry) {
320
+ const data = isRecord(entry['data']) ? entry['data'] : null;
321
+ if (data === null)
322
+ return null;
323
+ const chunk = isRecord(data['chunk']) ? data['chunk'] : null;
324
+ if (chunk === null || chunk['type'] !== 'usage')
325
+ return null;
326
+ return usageEvent(chunk['usage']);
327
+ }
328
+ export function createDshLiveMapper() {
329
+ const stepsWithDeltas = new Set();
330
+ return (entry) => {
331
+ const data = isRecord(entry['data']) ? entry['data'] : null;
332
+ if (data === null)
333
+ return [];
334
+ switch (entry['type']) {
335
+ case 'text-chunks':
336
+ case 'reasoning-chunks': {
337
+ const texts = data['texts'];
338
+ if (!Array.isArray(texts))
339
+ return [];
340
+ const delta = bounded(texts.filter((t) => typeof t === 'string').join(''), 100_000);
341
+ if (delta === undefined || delta === '')
342
+ return [];
343
+ const key = stepKey(data);
344
+ if (key !== null)
345
+ stepsWithDeltas.add(key);
346
+ return [
347
+ {
348
+ kind: entry['type'] === 'reasoning-chunks' ? 'thought_delta' : 'message_delta',
349
+ delta,
350
+ },
351
+ ];
352
+ }
353
+ case 'assistant/chunk': {
354
+ // Verbatim delta events (runs below the codec's MIN_RUN). The usage /
355
+ // block-start / block-end / finish / tool-call-delta chunks carry no
356
+ // renderable text — usage rides the commit instead.
357
+ const chunk = isRecord(data['chunk']) ? data['chunk'] : null;
358
+ if (chunk === null)
359
+ return [];
360
+ const kind = chunk['type'] === 'text-delta'
361
+ ? 'message_delta'
362
+ : chunk['type'] === 'reasoning-delta'
363
+ ? 'thought_delta'
364
+ : null;
365
+ if (kind === null)
366
+ return [];
367
+ const text = bounded(chunk['text'], 100_000);
368
+ if (text === undefined || text === '')
369
+ return [];
370
+ const key = stepKey(data);
371
+ if (key !== null)
372
+ stepsWithDeltas.add(key);
373
+ return [{ kind, delta: text }];
374
+ }
375
+ case 'assistant/message': {
376
+ const out = [];
377
+ const key = stepKey(data);
378
+ const streamed = key !== null && stepsWithDeltas.delete(key);
379
+ if (!streamed) {
380
+ const message = isRecord(data['message']) ? data['message'] : null;
381
+ const content = message !== null ? message['content'] : undefined;
382
+ if (Array.isArray(content)) {
383
+ for (const rawBlock of content) {
384
+ if (!isRecord(rawBlock))
385
+ continue;
386
+ const block = rawBlock;
387
+ if (block['type'] !== 'text' && block['type'] !== 'reasoning')
388
+ continue;
389
+ const text = bounded(block['text'], 100_000);
390
+ if (text !== undefined && text !== '') {
391
+ out.push({
392
+ kind: block['type'] === 'reasoning' ? 'thought_delta' : 'message_delta',
393
+ delta: text,
394
+ });
395
+ }
396
+ }
397
+ }
398
+ }
399
+ const usage = usageEvent(data['usage']);
400
+ if (usage !== null)
401
+ out.push(usage);
402
+ return out;
403
+ }
404
+ case 'tool/call': {
405
+ const event = toolStartEvent(data['callId'], data['name'], data['arguments']);
406
+ return event === null ? [] : [event];
407
+ }
408
+ case 'tool/result': {
409
+ const message = isRecord(data['message']) ? data['message'] : null;
410
+ const event = toolResultEvent(message);
411
+ return event === null ? [] : [event];
412
+ }
413
+ default:
414
+ return [];
415
+ }
416
+ };
417
+ }
418
+ /**
419
+ * Map dsh transcript entries onto history items (user blocks + ordinary
420
+ * stream events — ONE fold path in the browser). Genuine user turns are the
421
+ * `agent/inbox/spliced` inserts; `user/message` echoes and the synthesized
422
+ * runtime-context messages are skipped.
423
+ */
424
+ export function dshHistoryItems(entries) {
425
+ const items = [];
426
+ for (const entry of entries) {
427
+ const type = entry['type'];
428
+ const data = isRecord(entry['data']) ? entry['data'] : null;
429
+ if (data === null)
430
+ continue;
431
+ switch (type) {
432
+ case 'agent/inbox/spliced': {
433
+ const inserted = data['inserted'];
434
+ if (!Array.isArray(inserted))
435
+ break;
436
+ for (const raw of inserted) {
437
+ if (!isRecord(raw))
438
+ continue;
439
+ const source = isRecord(raw['source']) ? raw['source'] : null;
440
+ if (source === null || source['kind'] !== 'user')
441
+ continue;
442
+ const blocks = toPromptBlocks(raw['content']);
443
+ if (blocks.length > 0)
444
+ items.push({ type: 'user', blocks });
445
+ }
446
+ break;
447
+ }
448
+ case 'assistant/message': {
449
+ const message = isRecord(data['message']) ? data['message'] : null;
450
+ const content = message !== null ? message['content'] : data['content'];
451
+ if (!Array.isArray(content))
452
+ break;
453
+ for (const rawBlock of content) {
454
+ if (!isRecord(rawBlock))
455
+ continue;
456
+ const block = rawBlock;
457
+ if (block['type'] === 'reasoning') {
458
+ const text = bounded(block['text'], 100_000);
459
+ if (text !== undefined && text !== '') {
460
+ items.push({ type: 'event', event: { kind: 'thought_delta', delta: text } });
461
+ }
462
+ }
463
+ else if (block['type'] === 'text') {
464
+ const text = bounded(block['text'], 100_000);
465
+ if (text !== undefined && text !== '') {
466
+ items.push({ type: 'event', event: { kind: 'message_delta', delta: text } });
467
+ }
468
+ }
469
+ else if (block['type'] === 'tool-call') {
470
+ const event = toolStartEvent(block['id'], block['name'], block['arguments']);
471
+ if (event !== null)
472
+ items.push({ type: 'event', event });
473
+ }
474
+ }
475
+ break;
476
+ }
477
+ case 'tool/call': {
478
+ const event = toolStartEvent(data['callId'], data['name'], data['arguments']);
479
+ if (event !== null)
480
+ items.push({ type: 'event', event });
481
+ break;
482
+ }
483
+ case 'tool/result': {
484
+ const message = isRecord(data['message']) ? data['message'] : null;
485
+ const event = toolResultEvent(message);
486
+ if (event !== null)
487
+ items.push({ type: 'event', event });
488
+ break;
489
+ }
490
+ case 'assistant/chunk': {
491
+ // Only the usage marker rides the history (the text chunks are
492
+ // superseded by assistant/message's complete blocks).
493
+ const event = usageFromChunk(entry);
494
+ if (event !== null)
495
+ items.push({ type: 'event', event });
496
+ break;
497
+ }
498
+ case 'turn/end': {
499
+ const reason = isRecord(data['reason']) ? data['reason'] : null;
500
+ items.push({
501
+ type: 'event',
502
+ event: {
503
+ kind: 'turn_result',
504
+ stopReason: reason?.['kind'] === 'cancelled' ? 'cancelled' : 'end_turn',
505
+ },
506
+ });
507
+ break;
508
+ }
509
+ default:
510
+ break; // presets, request/*, step markers, deltas, title — not history
511
+ }
512
+ }
513
+ return items;
514
+ }
515
+ /** dsh content blocks → prompt blocks (text only in v1, matching our wire). */
516
+ function toPromptBlocks(content) {
517
+ const blocks = [];
518
+ if (!Array.isArray(content))
519
+ return blocks;
520
+ for (const raw of content) {
521
+ if (isRecord(raw) && raw['type'] === 'text' && typeof raw['text'] === 'string') {
522
+ if (raw['text'] !== '')
523
+ blocks.push({ type: 'text', text: bounded(raw['text'], 32_000) });
524
+ }
525
+ }
526
+ return blocks;
527
+ }
528
+ // ---- live transcript tail (dsh's streaming source) ----
529
+ /** Locate a session's transcript by id across the slug dirs (null = absent). */
530
+ export function findTranscript(sessionsDir, sessionId, readdir) {
531
+ let slugs;
532
+ try {
533
+ slugs = readdir(sessionsDir);
534
+ }
535
+ catch {
536
+ return null;
537
+ }
538
+ for (const slug of slugs) {
539
+ const candidate = `${sessionsDir}/${slug}/${sessionId}/session.jsonl.zstd`;
540
+ try {
541
+ readdir(`${sessionsDir}/${slug}/${sessionId}`);
542
+ return candidate; // the session dir exists — the file follows on first write
543
+ }
544
+ catch {
545
+ // not under this slug
546
+ }
547
+ }
548
+ return null;
549
+ }
550
+ /**
551
+ * Tails a dsh transcript for LIVE token streaming. Ground truth (dsh
552
+ * 0.1.2-rc.1 write path): every engine `assistant/chunk` event rides the
553
+ * session bus into a bounded write-behind batch (~100–300ms window) and lands
554
+ * as one zstd frame per batch — so polling the file yields token batches
555
+ * roughly one write window behind the model, while dsh's ACP adapter stays
556
+ * silent until commit. New bytes are split into frames, decoded, and mapped
557
+ * through a stateful `createDshLiveMapper` (packed rows AND verbatim chunk
558
+ * events, with the `turn:step` committed-dedup — see its doc).
559
+ *
560
+ * A trailing frame cut mid-write decodes only once its remaining bytes
561
+ * arrive: the last undecodable slice is buffered for the next poll (the
562
+ * writer completes it on the next batch or truncates it back on rollback).
563
+ * `start()` skips existing bytes (history was already rendered);
564
+ * `flush()` drains once more so a turn's final tokens stay ordered before
565
+ * its turn_result. A BOUNDED frame that fails to decode is corruption —
566
+ * `onFatal` fires and the tail stops (the committed path takes over).
567
+ */
568
+ export class TranscriptTail {
569
+ file;
570
+ fs;
571
+ decompress;
572
+ onEvent;
573
+ opts;
574
+ pending = Buffer.alloc(0);
575
+ timer = null;
576
+ stopped = false;
577
+ offset = 0;
578
+ mapper = createDshLiveMapper();
579
+ /** Wall-clock of the last `turn/end` row seen (0 = none). */
580
+ lastTurnEndAt = 0;
581
+ constructor(file, fs, decompress, onEvent, opts = {}) {
582
+ this.file = file;
583
+ this.fs = fs;
584
+ this.decompress = decompress;
585
+ this.onEvent = onEvent;
586
+ this.opts = opts;
587
+ }
588
+ /** Begin polling. `replayFromBeginning` re-maps the whole file instead of
589
+ * skipping existing bytes — valid ONLY when nothing in it was ever
590
+ * rendered (a lazily-materialized new-session transcript attached mid-turn:
591
+ * replaying recovers the deltas that landed before the attach; the
592
+ * caller guarantees no earlier turn ran through the committed path). */
593
+ start(replayFromBeginning = false) {
594
+ this.offset = replayFromBeginning ? 0 : (this.fs.size(this.file) ?? 0);
595
+ this.timer = setInterval(() => this.poll(), this.opts.intervalMs ?? 250);
596
+ this.timer.unref?.();
597
+ }
598
+ /** Whether a `turn/end` row landed since `since` (the settle signal). */
599
+ turnEndSeenSince(since) {
600
+ return this.lastTurnEndAt >= since;
601
+ }
602
+ /** One synchronous drain (call before settling a turn). */
603
+ flush() {
604
+ if (!this.stopped)
605
+ this.poll();
606
+ }
607
+ stop() {
608
+ this.stopped = true;
609
+ if (this.timer !== null)
610
+ clearInterval(this.timer);
611
+ this.timer = null;
612
+ this.pending = Buffer.alloc(0);
613
+ }
614
+ poll() {
615
+ const size = this.fs.size(this.file);
616
+ if (size === null || size <= this.offset)
617
+ return;
618
+ let chunk;
619
+ try {
620
+ chunk = this.fs.readEnd(this.file, this.offset);
621
+ }
622
+ catch {
623
+ return;
624
+ }
625
+ this.offset += chunk.length;
626
+ this.pending = Buffer.concat([this.pending, chunk]);
627
+ this.drain();
628
+ }
629
+ drain() {
630
+ const frames = splitZstdFrames(this.pending);
631
+ let consumed = 0;
632
+ for (let i = 0; i < frames.length; i++) {
633
+ const frame = frames[i];
634
+ let text;
635
+ try {
636
+ text = this.decompress(frame).toString('utf8');
637
+ }
638
+ catch {
639
+ if (i === frames.length - 1)
640
+ break; // partial trailing frame — wait
641
+ // A bounded frame that won't decode is mid-file corruption: frames
642
+ // are self-terminating, so the mapper can no longer be trusted to
643
+ // see every commit. Stop and let the committed wire path take over.
644
+ const reason = `transcript corrupt at byte ${this.offset}: undecodable frame`;
645
+ this.stop();
646
+ this.opts.onFatal?.(reason);
647
+ return;
648
+ }
649
+ consumed += frame.length;
650
+ this.emitLines(text);
651
+ }
652
+ this.pending = this.pending.subarray(consumed);
653
+ }
654
+ emitLines(text) {
655
+ for (const line of text.split('\n')) {
656
+ if (line.trim() === '')
657
+ continue;
658
+ let entry;
659
+ try {
660
+ entry = JSON.parse(line);
661
+ }
662
+ catch {
663
+ continue;
664
+ }
665
+ if (!isRecord(entry))
666
+ continue;
667
+ if (entry['type'] === 'turn/end')
668
+ this.lastTurnEndAt = Date.now();
669
+ for (const event of this.mapper(entry))
670
+ this.onEvent(event);
671
+ }
672
+ }
673
+ }
674
+ //# sourceMappingURL=dsh-sessions.js.map