@itookit/dsht 0.3.2 → 0.3.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 (55) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -20
  3. package/README.zh.md +21 -20
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -126
  7. package/dist/controller/controller.d.ts +18 -1
  8. package/dist/controller/controller.js +24 -2
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/config.d.ts +17 -0
  12. package/dist/cost/config.js +68 -0
  13. package/dist/cost/controller.d.ts +9 -2
  14. package/dist/cost/controller.js +10 -2
  15. package/dist/cost/index.d.ts +5 -4
  16. package/dist/cost/index.js +4 -3
  17. package/dist/cost/ledger-files.d.ts +4 -8
  18. package/dist/cost/ledger-files.js +46 -71
  19. package/dist/cost/ledger.d.ts +33 -20
  20. package/dist/cost/ledger.js +78 -68
  21. package/dist/cost/pricing.d.ts +55 -17
  22. package/dist/cost/pricing.js +126 -44
  23. package/dist/cost/records.js +18 -5
  24. package/dist/cost/types.d.ts +34 -18
  25. package/dist/cost/types.js +4 -0
  26. package/dist/session/controller.d.ts +16 -0
  27. package/dist/session/controller.js +33 -0
  28. package/dist/session/navigation.d.ts +80 -0
  29. package/dist/session/navigation.js +107 -0
  30. package/dist/session/transcript.d.ts +48 -6
  31. package/dist/session/transcript.js +117 -14
  32. package/dist/storage/files.d.ts +8 -0
  33. package/dist/storage/files.js +17 -0
  34. package/dist/storage/heap-snapshot.d.ts +19 -0
  35. package/dist/storage/heap-snapshot.js +29 -0
  36. package/dist/storage/index.d.ts +2 -1
  37. package/dist/storage/index.js +2 -1
  38. package/dist/ui/app.js +116 -10
  39. package/dist/ui/chat/history-view.d.ts +5 -1
  40. package/dist/ui/chat/history-view.js +9 -4
  41. package/dist/ui/chat/status.d.ts +16 -4
  42. package/dist/ui/chat/status.js +112 -32
  43. package/dist/ui/commands/parse.d.ts +3 -0
  44. package/dist/ui/commands/parse.js +5 -0
  45. package/dist/ui/commands/registry.js +1 -0
  46. package/dist/ui/dialogs/cost.js +3 -3
  47. package/dist/ui/dialogs/index.d.ts +6 -2
  48. package/dist/ui/dialogs/index.js +3 -3
  49. package/dist/ui/dialogs/picker.d.ts +21 -3
  50. package/dist/ui/dialogs/picker.js +37 -5
  51. package/dist/ui/input/input.d.ts +18 -3
  52. package/dist/ui/input/input.js +61 -22
  53. package/dist/ui/input/viewport.d.ts +96 -0
  54. package/dist/ui/input/viewport.js +173 -0
  55. package/package.json +3 -3
@@ -34,3 +34,110 @@ export function resolveTarget(items, query, id, names) {
34
34
  throw new Error(`Ambiguous target: ${target}. Use a full ID.`);
35
35
  throw new Error(`Target not found: ${target}`);
36
36
  }
37
+ /** Classify one session from the host's list summary; nothing is inferred from silence.
38
+ * @param session - Session summary from `session/list`.
39
+ * @param pending - Whether this client holds an unanswered interaction for that session.
40
+ * @returns Needs-you while an answer is owed, running while its agent works, otherwise idle or blank.
41
+ */
42
+ export function sessionState(session, pending = false) {
43
+ if (pending)
44
+ return 'needs';
45
+ if (session.running === true)
46
+ return 'running';
47
+ return session.blank === true ? 'blank' : 'idle';
48
+ }
49
+ /** Leading marker per state: a question mark, a working clock, a filled dot, and an unused circle. */
50
+ export const SESSION_MARKERS = { needs: '?', running: '◐', idle: '●', blank: '○' };
51
+ /** One word per state, so every screen names the same state the same way. */
52
+ export const STATE_LABELS = { needs: 'needs you', running: 'working', idle: 'ready', blank: 'empty' };
53
+ /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
54
+ * @param time - Epoch milliseconds of the last activity, when the summary reported one.
55
+ * @param now - Current epoch milliseconds.
56
+ * @returns `now`, minutes, hours or days.
57
+ */
58
+ export function activityAge(time, now) {
59
+ if (time === undefined || !Number.isFinite(time))
60
+ return '';
61
+ const seconds = Math.max(0, Math.floor((now - time) / 1000));
62
+ if (seconds < 60)
63
+ return 'now';
64
+ const minutes = Math.floor(seconds / 60);
65
+ if (minutes < 60)
66
+ return `${minutes}m`;
67
+ const hours = Math.floor(minutes / 60);
68
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
69
+ }
70
+ /** Status cell for one session row: its state marker and the age of its last activity.
71
+ * @param session - Session summary from `session/list`.
72
+ * @param now - Current epoch milliseconds.
73
+ * @param pending - Whether this client holds an unanswered interaction for that session.
74
+ * @returns Marker with an optional age, without a trailing space when unknown.
75
+ */
76
+ export function sessionStatus(session, now, pending = false) {
77
+ const age = activityAge(typeof session.updatedAt === 'number' ? session.updatedAt : undefined, now);
78
+ return `${SESSION_MARKERS[sessionState(session, pending)]}${age === '' ? '' : ` ${age}`}`;
79
+ }
80
+ /** States a workspace rollup reports, most actionable first. */
81
+ export const ROLLUP_STATES = ['needs', 'running', 'idle'];
82
+ /** Count the sessions of one workspace by the state each reports.
83
+ *
84
+ * Blank sessions are counted by neither a badge nor a word: a session that never sent a turn is the
85
+ * absence of activity, and listing it beside real work only makes the rollup harder to read.
86
+ * @param sessions - Sessions whose `sessionIds` belong to the workspace.
87
+ * @param pending - Session IDs this client holds an unanswered interaction for.
88
+ * @returns One count per state that occurs, most actionable first, or an empty list.
89
+ */
90
+ export function workspaceCounts(sessions, pending = new Set()) {
91
+ const counts = { needs: 0, running: 0, idle: 0 };
92
+ for (const session of sessions) {
93
+ const id = session.sessionId;
94
+ const state = sessionState(session, typeof id === 'string' && pending.has(id));
95
+ if (state !== 'blank')
96
+ counts[state] += 1;
97
+ }
98
+ return ROLLUP_STATES.filter(state => counts[state] > 0).map(state => ({ state, count: counts[state] }));
99
+ }
100
+ /** Render one rollup as separately coloured cells.
101
+ *
102
+ * Each cell after the first carries the separator that joins it to the previous one, so a caller can
103
+ * colour the cells independently without losing the text {@link workspaceStatus} would produce.
104
+ * @param counts - Counts from {@link workspaceCounts}.
105
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
106
+ * @returns The cells in the order given, with their separators.
107
+ */
108
+ export function workspaceSegments(counts, style = 'words') {
109
+ const separator = style === 'words' ? ' · ' : ' ';
110
+ return counts.map(({ state, count }, index) => ({ state,
111
+ text: `${index === 0 ? '' : separator}${style === 'words' ? `${SESSION_MARKERS[state]} ${count} ${STATE_LABELS[state]}` : `${SESSION_MARKERS[state]}${count}`}` }));
112
+ }
113
+ /** Render one rollup as plain text, the same way every screen and test reads it.
114
+ * @param counts - Counts from {@link workspaceCounts}.
115
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
116
+ * @returns The joined cell text, empty when nothing was counted.
117
+ */
118
+ export function workspaceStatus(counts, style = 'words') {
119
+ return workspaceSegments(counts, style).map(segment => segment.text).join('');
120
+ }
121
+ /** Marker key for the compact rollup, which has no room for the words. */
122
+ export const ROLLUP_LEGEND = `${SESSION_MARKERS.idle} ${STATE_LABELS.idle} · ${SESSION_MARKERS.running} ${STATE_LABELS.running} · ${SESSION_MARKERS.needs} ${STATE_LABELS.needs}`;
123
+ /** Secondary path text for one workspace row.
124
+ *
125
+ * The title is usually the last path segment, so repeating it wastes the row; the parent directory
126
+ * is what distinguishes two checkouts. A title that does not name the last segment keeps the full
127
+ * path, because dropping it would hide where the workspace actually lives.
128
+ * @param path - Registered host directory.
129
+ * @param title - Workspace title as the row already shows it.
130
+ * @returns The path to show beside the row, or an empty string when nothing is left.
131
+ */
132
+ export function workspaceDetail(path, title) {
133
+ if (!path)
134
+ return '';
135
+ const trimmed = path.replace(/\/+$/u, '');
136
+ const cut = trimmed.lastIndexOf('/');
137
+ const base = cut < 0 ? trimmed : trimmed.slice(cut + 1);
138
+ if (base !== title)
139
+ return path;
140
+ if (cut > 0)
141
+ return trimmed.slice(0, cut);
142
+ return cut === 0 ? '/' : '';
143
+ }
@@ -28,9 +28,9 @@ export interface MessagePart {
28
28
  /** Stable identity of a live part, so its rows can be wrapped incrementally as it grows. */
29
29
  key?: string;
30
30
  }
31
- /** The stripe of live work the unfinished attempt is in. */
31
+ /** The stripe of work the live attempt last moved into; the status bar times it until a newer one. */
32
32
  export interface LivePhase {
33
- /** Which kind of delta last moved the attempt forward. */
33
+ /** Which kind of work the newest event named. */
34
34
  kind: 'thinking' | 'tool' | 'output';
35
35
  /** Tool name when `kind` is `tool`, so the bar can name what is running. */
36
36
  name?: string;
@@ -71,8 +71,14 @@ export declare class Transcript {
71
71
  private closedBlocks;
72
72
  private oldestSeq;
73
73
  private turnMarker;
74
+ /** Pending-tool lookup memo, keyed by the revision that produced it. */
75
+ private runningCache;
74
76
  private attempt;
75
77
  private phase;
78
+ /** Whether the newest delta still extends `phase`; attempt boundaries close it without dropping it. */
79
+ private phaseOpen;
80
+ /** Set when an added record can change which call the open turn is still waiting on. */
81
+ private pendingDirty;
76
82
  private nextIndex;
77
83
  private revision;
78
84
  private legacyStream;
@@ -109,12 +115,29 @@ export declare class Transcript {
109
115
  private dropProjections;
110
116
  private storeEvent;
111
117
  private deleteEvent;
112
- /** The stripe of work the live attempt is in: the phase of the block that last took a delta.
118
+ /** The stripe of work the live attempt is in: the newest event the bar can time.
113
119
  *
114
- * Its age answers "what is it doing, and for how long" without inferring anything from silence.
120
+ * Its age answers "what is it doing, and for how long" without inferring anything from silence,
121
+ * so the answer outlives the stream that supplied it: a tool keeps its age while it runs and after
122
+ * it answers, until the next stripe starts or the turn closes. Only the turn's own boundaries
123
+ * withdraw the phase, because everything inside a turn is still work the bar should be timing.
115
124
  * @returns Phase kind, the tool name when the phase is a tool call, and when the phase began.
116
125
  */
117
126
  get livePhase(): LivePhase | undefined;
127
+ /** The tool the open turn asked for that has not answered yet, oldest first.
128
+ * @returns Call id, tool name and the moment the request was recorded, or undefined when none is in flight.
129
+ */
130
+ get runningTool(): {
131
+ id: string;
132
+ name: string;
133
+ startedAt: number;
134
+ } | undefined;
135
+ /** Find the open turn's unanswered tool call, which is the work a bar can still be waiting on.
136
+ *
137
+ * The host's `tool/call` event is not retained — only user-visible content is — so this reads the
138
+ * tool-call blocks a retained assistant message carries and the results that answer them.
139
+ */
140
+ private findPendingTool;
118
141
  /** Start of the open durable turn, when its timestamp is present in the retained window. */
119
142
  get activeTurnStartedAt(): number | undefined;
120
143
  /** Cached prompt/reasoning summaries, oldest first; stream deltas never invalidate this index. */
@@ -162,9 +185,28 @@ export declare class Transcript {
162
185
  */
163
186
  private foldLegacy;
164
187
  private validatePackedTiming;
165
- /** Drop the live attempt's blocks and the phase derived from them. */
188
+ /** Drop the live attempt's blocks; the phase they named stays until something newer replaces it. */
166
189
  private clearBlocks;
167
- /** Record the phase a delta moved the attempt into; the status bar shows its age. */
190
+ /** Withdraw the phase at a turn boundary, where no work is running under it any more. */
191
+ private clearPhase;
192
+ /** Move the phase to the call the open turn is still waiting on, which is work in progress.
193
+ *
194
+ * A tool runs after the assistant stream that asked for it has ended, so the live block no longer
195
+ * covers it; the call the host committed but did not answer is what keeps a long command visible
196
+ * instead of showing an idle bar. While deltas are still arriving they are the newer evidence, and
197
+ * a turn with nothing unanswered keeps the phase it has rather than dropping back to silence.
198
+ */
199
+ private settlePhase;
200
+ /** Record the stripe a delta moved the attempt into; the status bar shows its age.
201
+ *
202
+ * A delta that continues the open stripe keeps the original start, because the phase is one
203
+ * stretch of work rather than one chunk. A stripe that returns after the attempt closed it — a
204
+ * second `think` in a later step — starts over, and the call id is what distinguishes a repeated
205
+ * tool from the call that already ran.
206
+ * @param kind - Stripe the delta belongs to.
207
+ * @param name - Tool name, when the stripe is a tool call.
208
+ * @param id - Tool call id, when the stripe is a tool call.
209
+ */
168
210
  private notePhase;
169
211
  private chunk;
170
212
  }
@@ -76,8 +76,14 @@ export class Transcript {
76
76
  closedBlocks = new Set();
77
77
  oldestSeq;
78
78
  turnMarker;
79
+ /** Pending-tool lookup memo, keyed by the revision that produced it. */
80
+ runningCache;
79
81
  attempt;
80
82
  phase;
83
+ /** Whether the newest delta still extends `phase`; attempt boundaries close it without dropping it. */
84
+ phaseOpen = false;
85
+ /** Set when an added record can change which call the open turn is still waiting on. */
86
+ pendingDirty = false;
81
87
  nextIndex = 0;
82
88
  revision = 0;
83
89
  legacyStream = false;
@@ -116,6 +122,7 @@ export class Transcript {
116
122
  this.oldestSeq = undefined;
117
123
  this.turnMarker = undefined;
118
124
  this.clearBlocks();
125
+ this.clearPhase();
119
126
  this.keysDirty = true;
120
127
  this.projectionRevision++;
121
128
  this.legacyDirty = true;
@@ -173,6 +180,7 @@ export class Transcript {
173
180
  else if (live.type === 'end') {
174
181
  this.attempt = undefined;
175
182
  this.clearBlocks();
183
+ this.settlePhase();
176
184
  }
177
185
  else
178
186
  throw new Error('Unknown assistant stream frame');
@@ -181,6 +189,10 @@ export class Transcript {
181
189
  }
182
190
  default: throw new Error('Unknown session follow frame');
183
191
  }
192
+ if (this.pendingDirty) {
193
+ this.pendingDirty = false;
194
+ this.settlePhase();
195
+ }
184
196
  }
185
197
  /** Add an older page without replacing the live tail. */
186
198
  addPage(value) {
@@ -190,6 +202,10 @@ export class Transcript {
190
202
  const page = object(value);
191
203
  this.addRecords(array(page.records));
192
204
  this.hasMore = page.hasMore === true;
205
+ if (this.pendingDirty) {
206
+ this.pendingDirty = false;
207
+ this.settlePhase();
208
+ }
193
209
  }
194
210
  /** Durable read cutoff includes followed records that were not present in the opening snapshot. */
195
211
  get readThrough() { return Math.max(this.cursor, this.throughSeq); }
@@ -258,6 +274,7 @@ export class Transcript {
258
274
  this.sizes.clear();
259
275
  this.storedBytes = 0;
260
276
  this.clearBlocks();
277
+ this.clearPhase();
261
278
  this.transientSeqs.clear();
262
279
  this.sortedSeqs = [];
263
280
  this.displayed = [];
@@ -295,16 +312,57 @@ export class Transcript {
295
312
  this.sizes.delete(seq);
296
313
  this.events.delete(seq);
297
314
  }
298
- /** The stripe of work the live attempt is in: the phase of the block that last took a delta.
315
+ /** The stripe of work the live attempt is in: the newest event the bar can time.
299
316
  *
300
- * Its age answers "what is it doing, and for how long" without inferring anything from silence.
317
+ * Its age answers "what is it doing, and for how long" without inferring anything from silence,
318
+ * so the answer outlives the stream that supplied it: a tool keeps its age while it runs and after
319
+ * it answers, until the next stripe starts or the turn closes. Only the turn's own boundaries
320
+ * withdraw the phase, because everything inside a turn is still work the bar should be timing.
301
321
  * @returns Phase kind, the tool name when the phase is a tool call, and when the phase began.
302
322
  */
303
323
  get livePhase() {
304
- if (this.phase === undefined || this.blocks.size === 0)
324
+ const phase = this.phase;
325
+ return phase === undefined ? undefined
326
+ : { kind: phase.kind, ...(phase.name === undefined ? {} : { name: phase.name }), startedAt: phase.startedAt };
327
+ }
328
+ /** The tool the open turn asked for that has not answered yet, oldest first.
329
+ * @returns Call id, tool name and the moment the request was recorded, or undefined when none is in flight.
330
+ */
331
+ get runningTool() {
332
+ if (this.runningCache?.version === this.version)
333
+ return this.runningCache.value;
334
+ const value = this.findPendingTool();
335
+ this.runningCache = { version: this.version, value };
336
+ return value;
337
+ }
338
+ /** Find the open turn's unanswered tool call, which is the work a bar can still be waiting on.
339
+ *
340
+ * The host's `tool/call` event is not retained — only user-visible content is — so this reads the
341
+ * tool-call blocks a retained assistant message carries and the results that answer them.
342
+ */
343
+ findPendingTool() {
344
+ const marker = this.turnMarker;
345
+ // A turn that ended cannot have a tool in flight, and `turn/end` clears the marker's start.
346
+ if (marker?.start === undefined)
305
347
  return undefined;
306
- const { kind, name, startedAt } = this.phase;
307
- return { kind, ...(name === undefined ? {} : { name }), startedAt };
348
+ const calls = [];
349
+ const answered = new Set();
350
+ for (const seq of this.sortedKeys()) {
351
+ if (seq <= marker.seq)
352
+ continue;
353
+ const event = this.events.get(seq);
354
+ if (event.type !== 'assistant/message' && event.type !== 'tool/result')
355
+ continue;
356
+ const startedAt = typeof event.time === 'number' ? event.time : marker.start;
357
+ for (const block of array(object(object(event.data).message).content).map(object)) {
358
+ if (block.type === 'tool-call')
359
+ calls.push({ id: string(block.id), name: string(block.name ?? 'tool'), startedAt });
360
+ else if (block.type === 'tool-result')
361
+ answered.add(string(block.toolCallId));
362
+ }
363
+ }
364
+ const pending = calls.find(call => !answered.has(call.id));
365
+ return pending === undefined ? undefined : { id: pending.id, name: pending.name, startedAt: pending.startedAt };
308
366
  }
309
367
  /** Start of the open durable turn, when its timestamp is present in the retained window. */
310
368
  get activeTurnStartedAt() {
@@ -490,9 +548,16 @@ export class Transcript {
490
548
  this.throughSeq = Math.max(this.throughSeq, seq);
491
549
  if ((event.type === 'turn/start' || event.type === 'turn/end') && seq >= (this.turnMarker?.seq ?? -1)) {
492
550
  this.turnMarker = { seq, start: event.type === 'turn/start' && typeof event.time === 'number' && Number.isFinite(event.time) ? event.time : undefined };
551
+ // A turn is the span a phase belongs to: the closed one has nothing left running and the
552
+ // opening one has not moved yet, so neither may show the other's last event.
553
+ this.clearPhase();
493
554
  }
494
555
  const current = this.events.get(seq);
495
556
  this.storeEvent(seq, retainedEvent(event));
557
+ // Only these records can answer "which call is the open turn still waiting on".
558
+ if (event.type === 'assistant/message' || event.type === 'tool/result'
559
+ || event.type === 'turn/start' || event.type === 'turn/end')
560
+ this.pendingDirty = true;
496
561
  if (!DISPLAY_EVENTS.has(string(event.type)))
497
562
  this.transientSeqs.add(seq);
498
563
  else
@@ -580,14 +645,47 @@ export class Transcript {
580
645
  if (count === 0 || array(data.dt).length !== count - 1)
581
646
  throw new Error('Invalid packed history member count');
582
647
  }
583
- /** Drop the live attempt's blocks and the phase derived from them. */
584
- clearBlocks() { this.blocks.clear(); this.closedBlocks.clear(); this.phase = undefined; }
585
- /** Record the phase a delta moved the attempt into; the status bar shows its age. */
586
- notePhase(kind, name) {
587
- const key = kind === 'tool' ? `tool:${name ?? ''}` : kind;
648
+ /** Drop the live attempt's blocks; the phase they named stays until something newer replaces it. */
649
+ clearBlocks() { this.blocks.clear(); this.closedBlocks.clear(); this.phaseOpen = false; }
650
+ /** Withdraw the phase at a turn boundary, where no work is running under it any more. */
651
+ clearPhase() { this.phase = undefined; this.phaseOpen = false; }
652
+ /** Move the phase to the call the open turn is still waiting on, which is work in progress.
653
+ *
654
+ * A tool runs after the assistant stream that asked for it has ended, so the live block no longer
655
+ * covers it; the call the host committed but did not answer is what keeps a long command visible
656
+ * instead of showing an idle bar. While deltas are still arriving they are the newer evidence, and
657
+ * a turn with nothing unanswered keeps the phase it has rather than dropping back to silence.
658
+ */
659
+ settlePhase() {
660
+ if (this.phaseOpen)
661
+ return;
662
+ const tool = this.runningTool;
663
+ if (tool === undefined)
664
+ return;
665
+ const key = toolPhaseKey(tool.id, tool.name);
588
666
  if (this.phase?.key === key)
589
667
  return;
668
+ this.phase = { key, kind: 'tool', name: tool.name, startedAt: tool.startedAt };
669
+ }
670
+ /** Record the stripe a delta moved the attempt into; the status bar shows its age.
671
+ *
672
+ * A delta that continues the open stripe keeps the original start, because the phase is one
673
+ * stretch of work rather than one chunk. A stripe that returns after the attempt closed it — a
674
+ * second `think` in a later step — starts over, and the call id is what distinguishes a repeated
675
+ * tool from the call that already ran.
676
+ * @param kind - Stripe the delta belongs to.
677
+ * @param name - Tool name, when the stripe is a tool call.
678
+ * @param id - Tool call id, when the stripe is a tool call.
679
+ */
680
+ notePhase(kind, name, id) {
681
+ const key = kind === 'tool' ? toolPhaseKey(id ?? '', name) : kind;
682
+ if (this.phase?.key === key && this.phaseOpen) {
683
+ if (name !== undefined && this.phase.name !== name)
684
+ this.phase = { key, kind, name, startedAt: this.phase.startedAt };
685
+ return;
686
+ }
590
687
  this.phase = { key, kind, ...(name === undefined ? {} : { name }), startedAt: Date.now() };
688
+ this.phaseOpen = true;
591
689
  }
592
690
  chunk(chunk) {
593
691
  if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
@@ -606,8 +704,9 @@ export class Transcript {
606
704
  const index = number(chunk.index);
607
705
  const block = this.blocks.get(index);
608
706
  const name = typeof chunk.name === 'string' ? chunk.name : typeof block?.name === 'string' ? block.name : '';
609
- this.blocks.set(index, { type: 'tool-call', id: string(chunk.id), name, arguments: '' });
610
- this.notePhase('tool', name === '' ? undefined : name);
707
+ const id = typeof chunk.id === 'string' ? chunk.id : typeof block?.id === 'string' ? block.id : '';
708
+ this.blocks.set(index, { type: 'tool-call', id, name, arguments: '' });
709
+ this.notePhase('tool', name === '' ? undefined : name, id);
611
710
  }
612
711
  else if (chunk.type === 'block-end') {
613
712
  this.blocks.set(number(chunk.index), object(chunk.block));
@@ -620,12 +719,16 @@ function number(value) {
620
719
  throw new Error('Invalid sequence number');
621
720
  return value;
622
721
  }
722
+ /** Identity of one tool call, so two calls of the same tool do not share one phase. */
723
+ function toolPhaseKey(id, name) {
724
+ return `tool:${id === '' ? name ?? '' : id}`;
725
+ }
623
726
  /** Keep only user-visible content in durable client memory; the host owns raw tool results. */
624
727
  function retainedEvent(event) {
625
728
  if (!DISPLAY_EVENTS.has(string(event.type)))
626
729
  return event;
627
730
  if (event.surfaceOp !== 'append')
628
- return { seq: event.seq, type: event.type };
731
+ return { seq: event.seq, type: event.type, ...(typeof event.time === 'number' ? { time: event.time } : {}) };
629
732
  const data = object(event.data);
630
733
  const clean = (value) => {
631
734
  const block = object(value);
@@ -635,7 +738,7 @@ function retainedEvent(event) {
635
738
  return { type: 'tool-result', toolCallId: block.toolCallId, isError: block.isError === true };
636
739
  return block;
637
740
  };
638
- return { seq: event.seq, type: event.type, surfaceOp: 'append', data: event.type === 'user/message'
741
+ return { seq: event.seq, type: event.type, ...(typeof event.time === 'number' ? { time: event.time } : {}), surfaceOp: 'append', data: event.type === 'user/message'
639
742
  ? { content: data.content, ...(data.source ? { source: data.source } : {}) }
640
743
  : { message: { content: array(object(data.message).content).filter(block => event.type !== 'tool/result' || object(block).type === 'tool-result').map(clean) } } };
641
744
  }
@@ -9,6 +9,14 @@ export declare function readText(path: string): Promise<string | undefined>;
9
9
  * @returns File contents, or undefined when the file does not exist.
10
10
  */
11
11
  export declare function readPrivateFile(path: string, label: string): Promise<string | undefined>;
12
+ /** Move one private file within its directory, ignoring a source that is already gone.
13
+ *
14
+ * Used to set an unreadable file aside under a new name without deleting the bytes. A missing source
15
+ * is not an error: the caller listed the directory earlier and another process may have removed it.
16
+ * @param from - Existing path.
17
+ * @param to - Destination path, replaced when it already exists.
18
+ */
19
+ export declare function renameFile(from: string, to: string): Promise<void>;
12
20
  /** Replace a file atomically with owner-only contents, leaving no partial file behind.
13
21
  * @param path - Destination path.
14
22
  * @param contents - Complete file contents.
@@ -43,6 +43,23 @@ export async function readPrivateFile(path, label) {
43
43
  await handle.close();
44
44
  }
45
45
  }
46
+ /** Move one private file within its directory, ignoring a source that is already gone.
47
+ *
48
+ * Used to set an unreadable file aside under a new name without deleting the bytes. A missing source
49
+ * is not an error: the caller listed the directory earlier and another process may have removed it.
50
+ * @param from - Existing path.
51
+ * @param to - Destination path, replaced when it already exists.
52
+ */
53
+ export async function renameFile(from, to) {
54
+ try {
55
+ await rename(from, to);
56
+ }
57
+ catch (error) {
58
+ if (isMissing(error))
59
+ return;
60
+ throw error;
61
+ }
62
+ }
46
63
  /** Replace a file atomically with owner-only contents, leaving no partial file behind.
47
64
  * @param path - Destination path.
48
65
  * @param contents - Complete file contents.
@@ -0,0 +1,19 @@
1
+ /** Build a snapshot filename from a sampling-point tag and a time.
2
+ *
3
+ * The tag is a user label such as `after-stress`; everything that could steer the write outside the
4
+ * destination directory is folded into a dash, so a tag can never become a path.
5
+ * @param tag - Sampling-point label.
6
+ * @param time - Epoch milliseconds that keeps repeated snapshots distinct.
7
+ * @returns A `.heapsnapshot` filename that names one file in one directory.
8
+ */
9
+ export declare function heapSnapshotName(tag: string, time: number): string;
10
+ /** Write a V8 heap snapshot into one directory.
11
+ *
12
+ * V8 serializes the whole heap synchronously, so the client pauses while the file is written; the
13
+ * result is the artifact DevTools records, meant for offline leak analysis rather than display.
14
+ * @param directory - Destination directory.
15
+ * @param tag - Sampling-point label naming the file.
16
+ * @param write - Snapshot writer; injectable so tests do not have to serialize a heap.
17
+ * @returns Absolute path of the written snapshot.
18
+ */
19
+ export declare function writeHeapSnapshot(directory: string, tag?: string, write?: (file: string) => string): string;
@@ -0,0 +1,29 @@
1
+ /** V8 heap snapshots written to the working directory so memory growth can be inspected offline. */
2
+ import { join } from 'node:path';
3
+ import { writeHeapSnapshot as writeV8HeapSnapshot } from 'node:v8';
4
+ /** Longest sampling-point tag kept in a snapshot filename. */
5
+ const TAG_LIMIT = 48;
6
+ /** Build a snapshot filename from a sampling-point tag and a time.
7
+ *
8
+ * The tag is a user label such as `after-stress`; everything that could steer the write outside the
9
+ * destination directory is folded into a dash, so a tag can never become a path.
10
+ * @param tag - Sampling-point label.
11
+ * @param time - Epoch milliseconds that keeps repeated snapshots distinct.
12
+ * @returns A `.heapsnapshot` filename that names one file in one directory.
13
+ */
14
+ export function heapSnapshotName(tag, time) {
15
+ const safe = tag.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, TAG_LIMIT).replace(/[.-]+$/, '');
16
+ return `${safe || 'snapshot'}-${time}.heapsnapshot`;
17
+ }
18
+ /** Write a V8 heap snapshot into one directory.
19
+ *
20
+ * V8 serializes the whole heap synchronously, so the client pauses while the file is written; the
21
+ * result is the artifact DevTools records, meant for offline leak analysis rather than display.
22
+ * @param directory - Destination directory.
23
+ * @param tag - Sampling-point label naming the file.
24
+ * @param write - Snapshot writer; injectable so tests do not have to serialize a heap.
25
+ * @returns Absolute path of the written snapshot.
26
+ */
27
+ export function writeHeapSnapshot(directory, tag = 'snapshot', write = writeV8HeapSnapshot) {
28
+ return write(join(directory, heapSnapshotName(tag, Date.now())));
29
+ }
@@ -1,3 +1,4 @@
1
1
  /** Storage domain: every filesystem operation the client performs. */
2
- export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, writeExclusiveStream, writePrivateFile } from './files.ts';
2
+ export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from './files.ts';
3
3
  export { ensureDirectory, ensurePrivateDirectory, listEntries } from './directories.ts';
4
+ export { heapSnapshotName, writeHeapSnapshot } from './heap-snapshot.ts';
@@ -1,3 +1,4 @@
1
1
  /** Storage domain: every filesystem operation the client performs. */
2
- export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, writeExclusiveStream, writePrivateFile } from "./files.js";
2
+ export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from "./files.js";
3
3
  export { ensureDirectory, ensurePrivateDirectory, listEntries } from "./directories.js";
4
+ export { heapSnapshotName, writeHeapSnapshot } from "./heap-snapshot.js";