dsh-code 1.0.2 → 1.0.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 (53) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +285 -271
  3. package/bin/deepseek.mjs +26 -3
  4. package/lib/index.mjs +2962 -1560
  5. package/lib/types/app.d.ts +13 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/editor-keys.d.ts +105 -0
  8. package/lib/types/git-workflow.d.ts +6 -2
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/model-capabilities.d.ts +82 -0
  14. package/lib/types/provider-settings.d.ts +84 -0
  15. package/lib/types/render/lines.d.ts +25 -0
  16. package/lib/types/render/markdown.d.ts +1 -1
  17. package/lib/types/render/projection.d.ts +22 -2
  18. package/lib/types/render/status.d.ts +22 -15
  19. package/lib/types/render/text.d.ts +15 -9
  20. package/lib/types/render/width.d.ts +29 -0
  21. package/lib/types/session-directory.d.ts +27 -0
  22. package/lib/types/settings-file.d.ts +33 -0
  23. package/lib/types/skills.d.ts +1 -1
  24. package/lib/types/store.d.ts +10 -0
  25. package/lib/types/subagents.d.ts +13 -3
  26. package/package.json +159 -159
  27. package/src/app.ts +4514 -3892
  28. package/src/approval.ts +8 -3
  29. package/src/authorization-panel.ts +2 -4
  30. package/src/commands.ts +27 -3
  31. package/src/editor-keys.ts +371 -0
  32. package/src/git-workflow.ts +10 -6
  33. package/src/index.ts +1752 -1523
  34. package/src/input-split.ts +191 -0
  35. package/src/internals.ts +26 -8
  36. package/src/kernel-panels.ts +26 -10
  37. package/src/keyboard.ts +123 -88
  38. package/src/mentions.ts +42 -9
  39. package/src/model-capabilities.ts +318 -0
  40. package/src/provider-settings.ts +220 -0
  41. package/src/questions.ts +20 -0
  42. package/src/render/lines.ts +415 -356
  43. package/src/render/markdown.ts +18 -19
  44. package/src/render/projection.ts +162 -52
  45. package/src/render/status.ts +76 -71
  46. package/src/render/text.ts +158 -150
  47. package/src/render/width.ts +189 -0
  48. package/src/session-directory.ts +56 -0
  49. package/src/settings-file.ts +56 -0
  50. package/src/skills.ts +19 -6
  51. package/src/store.ts +26 -7
  52. package/src/subagents.ts +39 -6
  53. package/src/theme-panel.ts +79 -72
@@ -9,6 +9,8 @@
9
9
  * @module @deepseek-ai/dsh-code/render/markdown
10
10
  */
11
11
 
12
+ import { graphemeWidth, splitGraphemes, stringWidth } from './width.ts'
13
+
12
14
  /** Style classes the renderer emits; the app maps them to colors/props. */
13
15
  export type MdStyle = 'plain' | 'bold' | 'italic' | 'boldItalic' | 'code' | 'accent' | 'accentBold' | 'dim' | 'strike'
14
16
 
@@ -30,14 +32,9 @@ function seg(text: string, style: MdStyle = 'plain'): MdSegment {
30
32
  return { text, style }
31
33
  }
32
34
 
33
- /** Visible width of a run in columns (CJK counts double). */
35
+ /** Visible width of a run in columns (grapheme-cluster and emoji aware). */
34
36
  export function visibleColumns(text: string): number {
35
- let columns = 0
36
- for (const char of text) {
37
- const code = char.codePointAt(0) ?? 0
38
- columns += code > 0x2e7f ? 2 : 1
39
- }
40
- return columns
37
+ return stringWidth(text)
41
38
  }
42
39
 
43
40
  interface WrapUnit {
@@ -63,15 +60,17 @@ function wrapUnits(segments: readonly MdSegment[]): readonly WrapUnit[] {
63
60
  if (word !== '') units.push({ text: word, style: segment.style })
64
61
  word = ''
65
62
  }
66
- for (const char of segment.text) {
67
- if (char === ' ') {
63
+ // Grapheme clusters keep ZWJ families, flags, and combining sequences
64
+ // whole; a cluster wider than one cell is its own break opportunity.
65
+ for (const cluster of splitGraphemes(segment.text)) {
66
+ if (cluster === ' ') {
68
67
  flushWord()
69
- units.push({ text: char, style: segment.style })
70
- } else if (visibleColumns(char) > 1) {
68
+ units.push({ text: cluster, style: segment.style })
69
+ } else if (graphemeWidth(cluster) > 1) {
71
70
  flushWord()
72
- units.push({ text: char, style: segment.style })
71
+ units.push({ text: cluster, style: segment.style })
73
72
  } else {
74
- word += char
73
+ word += cluster
75
74
  }
76
75
  }
77
76
  flushWord()
@@ -102,7 +101,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
102
101
  const append = (unit: WrapUnit): void => {
103
102
  const columns = visibleColumns(unit.text)
104
103
  if (used === 0 && columns > limit) {
105
- for (const char of unit.text) appendAtom({ text: char, style: unit.style })
104
+ for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
106
105
  return
107
106
  }
108
107
  if (used + columns <= limit || current.length === 0) {
@@ -124,7 +123,7 @@ function wrapSegments(segments: readonly MdSegment[], width: number): readonly (
124
123
  }
125
124
  flush()
126
125
  if (columns > limit) {
127
- for (const char of unit.text) appendAtom({ text: char, style: unit.style })
126
+ for (const cluster of splitGraphemes(unit.text)) appendAtom({ text: cluster, style: unit.style })
128
127
  } else {
129
128
  appendAtom(unit)
130
129
  }
@@ -339,12 +338,12 @@ function hardWrapSegments(segments: readonly MdSegment[], width: number): readon
339
338
  used = 0
340
339
  }
341
340
  for (const segment of segments) {
342
- for (const char of segment.text) {
343
- const cells = visibleColumns(char)
341
+ for (const cluster of splitGraphemes(segment.text)) {
342
+ const cells = graphemeWidth(cluster)
344
343
  if (used > 0 && used + cells > width) flush()
345
344
  const previous = current.at(-1)
346
- if (previous?.style === segment.style) previous.text += char
347
- else current.push({ text: char, style: segment.style })
345
+ if (previous?.style === segment.style) previous.text += cluster
346
+ else current.push({ text: cluster, style: segment.style })
348
347
  used += cells
349
348
  }
350
349
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { boundContextSummary, type ContentBlock, type ImageBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
11
  import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
+ import { graphemeWidth, splitGraphemes } from './width.ts'
12
13
  // Type-only imports merge the plugin-owned SessionEventMap variants
13
14
  // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
14
15
  // plan/mode, permission/preset, sandbox/mode, session/title) into the union
@@ -158,6 +159,8 @@ export interface RetryEntry {
158
159
  kind: 'retry'
159
160
  /** Correlation id shared with the matching `llm/retry-started`. */
160
161
  retryId: string
162
+ /** Retry policy mode from the event: `always` has no attempt cap. */
163
+ mode: 'normal' | 'always'
161
164
  /** Attempt ordinal and its cap. */
162
165
  attempt: number
163
166
  max: number
@@ -165,7 +168,11 @@ export interface RetryEntry {
165
168
  code: string
166
169
  /** Backoff wait before the next attempt, in ms. */
167
170
  delayMs: number
168
- /** `running` while the backoff waits, `done` once the attempt started. */
171
+ /**
172
+ * `running` while the backoff waits, `done` once the attempt started — or
173
+ * when the turn ended first (the turn-end sweep finalizes orphans so they
174
+ * never pin the settled boundary).
175
+ */
169
176
  state: 'running' | 'done'
170
177
  }
171
178
 
@@ -327,6 +334,25 @@ function textOf(content: readonly ContentBlock[]): string {
327
334
  return content.filter(block => block.type === 'text').map(block => block.text).join('')
328
335
  }
329
336
 
337
+ /**
338
+ * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
339
+ * already handed to the renderer must never observe a later fold through a
340
+ * shared container. The collections are small and turn-bounded, so cloning
341
+ * per event is cheap next to the entries copy the reducer already makes.
342
+ */
343
+ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['anchors'] {
344
+ return {
345
+ stepStart: new Map(anchors.stepStart),
346
+ toolStart: new Map(anchors.toolStart),
347
+ firstChunkAt: new Map(anchors.firstChunkAt),
348
+ compactionTokens: new Map(anchors.compactionTokens),
349
+ lastPruneTokens: anchors.lastPruneTokens,
350
+ turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
351
+ turnSteps: new Map(anchors.turnSteps),
352
+ turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
353
+ }
354
+ }
355
+
330
356
  /** Durable image references in their model-visible order. */
331
357
  function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attachment'][] {
332
358
  return content.filter((block): block is ImageBlock => block.type === 'image').map(block => block.attachment)
@@ -336,13 +362,13 @@ function imagesOf(content: readonly ContentBlock[]): readonly ImageBlock['attach
336
362
  export function imageLabels(images: readonly ImageBlock['attachment'][] | undefined): string {
337
363
  if (images === undefined || images.length === 0) return ''
338
364
  return images.map((image, index) => {
339
- const rawName = image.name?.trim() || `image ${index + 1}`
340
- const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
341
- const original = image.originalDimensions
342
- const dimensions = original === undefined
343
- ? `${image.width}×${image.height}`
344
- : `${image.width}×${image.height} · original ${original.width}×${original.height}`
345
- return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
365
+ const rawName = image.name?.trim() || `image ${index + 1}`
366
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`
367
+ const original = image.originalDimensions
368
+ const dimensions = original === undefined
369
+ ? `${image.width}×${image.height}`
370
+ : `${image.width}×${image.height} · original ${original.width}×${original.height}`
371
+ return `[image: ${name} · ${dimensions} · ${image.bytes} B]`
346
372
  }).join('\n')
347
373
  }
348
374
 
@@ -368,8 +394,8 @@ function reasoningOf(content: readonly ContentBlock[]): string {
368
394
  function estimateTokens(text: string): number {
369
395
  let wide = 0
370
396
  let narrow = 0
371
- for (const char of text) {
372
- if ((char.codePointAt(0) ?? 0) > 0x2e7f) wide += 1
397
+ for (const cluster of splitGraphemes(text)) {
398
+ if (graphemeWidth(cluster) > 1) wide += 1
373
399
  else narrow += 1
374
400
  }
375
401
  return wide + Math.ceil(narrow / 4)
@@ -409,6 +435,10 @@ function pendingText(content: readonly ContentBlock[]): string {
409
435
  * @returns the view after the event; the input view is never mutated.
410
436
  */
411
437
  export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
438
+ // Fold against a private anchors block so the documented contract holds —
439
+ // "the input view is never mutated" — even for the in-place anchor sweeps
440
+ // below; without this, every handed-out view shared live Maps.
441
+ view = { ...view, anchors: cloneViewAnchors(view.anchors) }
412
442
  switch (event.type) {
413
443
  case 'user/message': {
414
444
  // A queued row retires when its durable user message lands (the agent
@@ -466,10 +496,16 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
466
496
  const { target, start, removedCount = 0, inserted } = event.data
467
497
  const ids = view.pending[target]
468
498
  const removed = ids.slice(start, start + removedCount)
499
+ // In-place upstream semantics: the kernel's authoritative fold is
500
+ // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
501
+ // AT the splice position (prepend/replace shapes), never at the tail.
502
+ // A tail append diverged the id order, so later coordinate-based events
503
+ // (next-turn head claims, positioned remove/replace) tombstoned the
504
+ // wrong pending row.
469
505
  const nextIds = [
470
506
  ...ids.slice(0, start),
471
- ...ids.slice(start + removedCount),
472
507
  ...inserted.map(message => message.id),
508
+ ...ids.slice(start + removedCount),
473
509
  ]
474
510
  let entries = view.entries
475
511
  if (removed.length > 0) {
@@ -713,8 +749,26 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
713
749
  for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
714
750
  view.anchors.turnTools.delete(event.data.turn)
715
751
  }
752
+ // Orphaned retry/command rows can never be resolved after the turn
753
+ // ends: an aborted retry backoff returns upstream without its
754
+ // `llm/retry-started`, and crash repair synthesizes only tool/step/
755
+ // turn closers. Left `running` they pin the settled boundary forever,
756
+ // so the turn end finalizes them exactly like the anchor sweep above.
757
+ let orphans = false
758
+ const swept = view.entries.map((entry) => {
759
+ if (entry.kind === 'retry' && entry.state === 'running') {
760
+ orphans = true
761
+ return { ...entry, state: 'done' as const }
762
+ }
763
+ if (entry.kind === 'command' && entry.state === 'running') {
764
+ orphans = true
765
+ return { ...entry, state: 'error' as const, summary: 'interrupted before the turn ended' }
766
+ }
767
+ return entry
768
+ })
769
+ const entries = orphans ? swept : view.entries
716
770
  if (appended.length === 0) {
717
- return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '' }
771
+ return { ...view, busy: false, busySince: 0, streaming: '', streamingReasoning: '', entries }
718
772
  }
719
773
  return {
720
774
  ...view,
@@ -722,7 +776,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
722
776
  busySince: 0,
723
777
  streaming: '',
724
778
  streamingReasoning: '',
725
- entries: [...view.entries, ...appended],
779
+ entries: [...entries, ...appended],
726
780
  }
727
781
  }
728
782
  case 'llm/retry': {
@@ -734,6 +788,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
734
788
  entries: [...view.entries, {
735
789
  kind: 'retry',
736
790
  retryId: data.retryId,
791
+ mode: data.mode,
737
792
  attempt: data.retry,
738
793
  max: 'maxRetries' in data ? data.maxRetries : data.retry,
739
794
  code: data.failure.code,
@@ -994,6 +1049,31 @@ function indexList(map: Map<string, number[]>, id: string): number[] {
994
1049
  return list
995
1050
  }
996
1051
 
1052
+ /**
1053
+ * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1054
+ * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1055
+ * settles `error` with an interruption note. Only the id-indexed rows are
1056
+ * visited, so the sweep stays O(retries+commands of the log), never a scan.
1057
+ */
1058
+ function finalizeReplayOrphans(acc: ReplayAccumulator): void {
1059
+ for (const list of acc.retryIndex.values()) {
1060
+ for (const index of list) {
1061
+ const entry = acc.entries[index]
1062
+ if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1063
+ acc.entries[index] = { ...entry, state: 'done' }
1064
+ }
1065
+ }
1066
+ }
1067
+ for (const list of acc.commandIndex.values()) {
1068
+ for (const index of list) {
1069
+ const entry = acc.entries[index]
1070
+ if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1071
+ acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' }
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+
997
1077
  /**
998
1078
  * Apply an id-keyed update to every row that registered the id, mirroring the
999
1079
  * copy-on-write reducer's full-array map semantics (all matching rows update,
@@ -1035,8 +1115,11 @@ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
1035
1115
  * to a sequential fold; only the `entries` container operations are mutable.
1036
1116
  *
1037
1117
  * @internal Test-instrumentation path; `projectEvents` is the public entry.
1118
+ * @returns whether the event changed the accumulated state — the live store
1119
+ * stays silent and keeps its snapshot identity for ignored events, exactly
1120
+ * like the copy-on-write reducer returning its input view unchanged.
1038
1121
  */
1039
- export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): void {
1122
+ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): boolean {
1040
1123
  switch (event.type) {
1041
1124
  case 'user/message': {
1042
1125
  const message = event.data
@@ -1066,7 +1149,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1066
1149
  prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1067
1150
  },
1068
1151
  }
1069
- return
1152
+ return true
1070
1153
  }
1071
1154
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1072
1155
  ? message.source.summary
@@ -1080,7 +1163,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1080
1163
  system: acc.stats.contextSegments.system + estimateTokens(summary),
1081
1164
  },
1082
1165
  }
1083
- return
1166
+ return true
1084
1167
  }
1085
1168
  case 'agent/inbox/spliced': {
1086
1169
  const { target, start, removedCount = 0, inserted } = event.data
@@ -1099,14 +1182,18 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1099
1182
  }
1100
1183
  }
1101
1184
  }
1185
+ // Mirror the reducer's in-place order: inserted ids join at the splice
1186
+ // position (upstream `splice(start, removedCount, ...inserted)`), never
1187
+ // at the tail — the id list must stay coordinate-compatible with every
1188
+ // later inbox event.
1189
+ ids.splice(start, 0, ...inserted.map(message => message.id))
1102
1190
  for (const message of inserted) {
1103
1191
  const images = imagesOf(message.content)
1104
1192
  appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }) })
1105
1193
  indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1106
- ids.push(message.id)
1107
1194
  acc.ops += 1
1108
1195
  }
1109
- return
1196
+ return true
1110
1197
  }
1111
1198
  case 'assistant/chunk': {
1112
1199
  const chunk = event.data.chunk
@@ -1125,13 +1212,13 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1125
1212
  }
1126
1213
  if (chunk.type === 'text-delta') {
1127
1214
  acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1128
- return
1215
+ return true
1129
1216
  }
1130
1217
  if (chunk.type === 'reasoning-delta') {
1131
1218
  acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1132
- return
1219
+ return true
1133
1220
  }
1134
- return
1221
+ return false
1135
1222
  }
1136
1223
  case 'assistant/message': {
1137
1224
  const key = `${event.data.turn}:${event.data.step}`
@@ -1165,7 +1252,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1165
1252
  assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1166
1253
  },
1167
1254
  }
1168
- return
1255
+ return true
1169
1256
  }
1170
1257
  case 'tool/call': {
1171
1258
  const data = event.data
@@ -1195,7 +1282,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1195
1282
  + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1196
1283
  },
1197
1284
  }
1198
- return
1285
+ return true
1199
1286
  }
1200
1287
  case 'tool/result': {
1201
1288
  const block = event.data.message.content[0]
@@ -1231,18 +1318,18 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1231
1318
  tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1232
1319
  },
1233
1320
  }
1234
- return
1321
+ return true
1235
1322
  }
1236
1323
  case 'todo/write':
1237
1324
  acc.todos = event.data.todos
1238
- return
1325
+ return true
1239
1326
  case 'turn/start': {
1240
1327
  const wasBusy = acc.busy
1241
1328
  acc.busy = true
1242
1329
  acc.busySince = wasBusy ? acc.busySince : event.time
1243
1330
  acc.todos = []
1244
1331
  acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1245
- return
1332
+ return true
1246
1333
  }
1247
1334
  case 'step/start': {
1248
1335
  const key = `${event.data.turn}:${event.data.step}`
@@ -1256,7 +1343,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1256
1343
  acc.streaming = ''
1257
1344
  acc.streamingReasoning = ''
1258
1345
  acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1259
- return
1346
+ return true
1260
1347
  }
1261
1348
  case 'turn/end': {
1262
1349
  const reason = event.data.reason
@@ -1294,10 +1381,11 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1294
1381
  for (const callId of turnToolSet) acc.toolStart.delete(callId)
1295
1382
  acc.turnTools.delete(event.data.turn)
1296
1383
  }
1384
+ finalizeReplayOrphans(acc)
1297
1385
  acc.busy = false
1298
1386
  acc.busySince = 0
1299
1387
  for (const entry of appended) appendReplayEntry(acc, entry)
1300
- return
1388
+ return true
1301
1389
  }
1302
1390
  case 'llm/retry': {
1303
1391
  const data = event.data
@@ -1306,6 +1394,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1306
1394
  appendReplayEntry(acc, {
1307
1395
  kind: 'retry',
1308
1396
  retryId: data.retryId,
1397
+ mode: data.mode,
1309
1398
  attempt: data.retry,
1310
1399
  max: 'maxRetries' in data ? data.maxRetries : data.retry,
1311
1400
  code: data.failure.code,
@@ -1313,23 +1402,23 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1313
1402
  state: 'running',
1314
1403
  })
1315
1404
  indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1316
- return
1405
+ return true
1317
1406
  }
1318
1407
  case 'llm/retry-started': {
1319
1408
  const data = event.data
1320
1409
  updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1321
- return
1410
+ return true
1322
1411
  }
1323
1412
  case 'sandbox/mode':
1324
1413
  acc.sandbox = event.data.mode
1325
- return
1414
+ return true
1326
1415
  case 'goal/change': {
1327
1416
  const data = event.data
1328
1417
  const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1329
1418
  if (data.operation === 'clear') {
1330
1419
  acc.goal = undefined
1331
1420
  appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1332
- return
1421
+ return true
1333
1422
  }
1334
1423
  const goal: GoalFold = {
1335
1424
  objective: data.goal.objective,
@@ -1351,31 +1440,31 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1351
1440
  : undefined
1352
1441
  acc.goal = goal
1353
1442
  if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1354
- return
1443
+ return true
1355
1444
  }
1356
1445
  case 'session/title':
1357
1446
  acc.title = event.data.title
1358
- return
1447
+ return true
1359
1448
  case 'compaction/summary':
1360
1449
  if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1361
1450
  const oldest = acc.compactionTokens.keys().next().value
1362
1451
  if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1363
1452
  }
1364
1453
  acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1365
- return
1454
+ return true
1366
1455
  case 'compaction/prune':
1367
1456
  acc.lastPruneTokens = event.data.shadowedTokenCount
1368
- return
1457
+ return true
1369
1458
  case 'compaction/end': {
1370
1459
  const ok = event.data.error === undefined
1371
1460
  const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1372
1461
  acc.compactionTokens.delete(event.data.compactionId)
1373
1462
  appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1374
- return
1463
+ return true
1375
1464
  }
1376
1465
  case 'request/context':
1377
1466
  acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1378
- return
1467
+ return true
1379
1468
  case 'request/header': {
1380
1469
  const config = event.data.header.config
1381
1470
  acc.model = `${config.provider}/${config.model}`
@@ -1387,14 +1476,14 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1387
1476
  system: estimateTokens(event.data.header.system ?? ''),
1388
1477
  },
1389
1478
  }
1390
- return
1479
+ return true
1391
1480
  }
1392
1481
  case 'plan/mode':
1393
1482
  acc.plan = event.data.active
1394
- return
1483
+ return true
1395
1484
  case 'permission/preset':
1396
1485
  acc.permission = event.data.preset
1397
- return
1486
+ return true
1398
1487
  case 'command/run': {
1399
1488
  const data = event.data
1400
1489
  appendReplayEntry(acc, {
@@ -1406,7 +1495,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1406
1495
  summary: '',
1407
1496
  })
1408
1497
  indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1409
- return
1498
+ return true
1410
1499
  }
1411
1500
  case 'command/done': {
1412
1501
  const data = event.data
@@ -1416,10 +1505,10 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1416
1505
  summary: boundContextSummary(data.text ?? ''),
1417
1506
  })
1418
1507
  updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1419
- return
1508
+ return true
1420
1509
  }
1421
1510
  default:
1422
- return
1511
+ return false
1423
1512
  }
1424
1513
  }
1425
1514
 
@@ -1431,8 +1520,27 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1431
1520
  * @internal Test-instrumentation path; `projectEvents` is the public entry.
1432
1521
  */
1433
1522
  export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1523
+ return materializeReplayView(acc, false)
1524
+ }
1525
+
1526
+ /**
1527
+ * Materialize the accumulated fold as a fresh immutable snapshot for the
1528
+ * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1529
+ * hands the accumulator's own arrays through because the accumulator is
1530
+ * discarded — every array a renderer can hold is copied here, so later
1531
+ * folds never mutate a snapshot already handed out. Same fields, same
1532
+ * tombstone compaction.
1533
+ *
1534
+ * @internal Live-store path; `projectEvents` is the public entry.
1535
+ */
1536
+ export function snapshotReplayView(acc: ReplayAccumulator): TranscriptView {
1537
+ return materializeReplayView(acc, true)
1538
+ }
1539
+
1540
+ /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1541
+ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): TranscriptView {
1434
1542
  const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1435
- ? acc.entries as TranscriptEntry[]
1543
+ ? (copy ? [...acc.entries] : acc.entries) as TranscriptEntry[]
1436
1544
  : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1437
1545
  if (acc.removedCount > 0) acc.ops += acc.entries.length
1438
1546
  return {
@@ -1451,15 +1559,17 @@ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1451
1559
  goal: acc.goal,
1452
1560
  pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1453
1561
  stats: acc.stats,
1562
+ // Handed-out views get their own anchors snapshot: the accumulator keeps
1563
+ // folding its live containers, and no consumer may observe that.
1454
1564
  anchors: {
1455
- stepStart: acc.stepStart,
1456
- toolStart: acc.toolStart,
1457
- firstChunkAt: acc.firstChunkAt,
1458
- compactionTokens: acc.compactionTokens,
1565
+ stepStart: new Map(acc.stepStart),
1566
+ toolStart: new Map(acc.toolStart),
1567
+ firstChunkAt: new Map(acc.firstChunkAt),
1568
+ compactionTokens: new Map(acc.compactionTokens),
1459
1569
  lastPruneTokens: acc.lastPruneTokens,
1460
- turnFiles: acc.turnFiles,
1461
- turnSteps: acc.turnSteps,
1462
- turnTools: acc.turnTools,
1570
+ turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1571
+ turnSteps: new Map(acc.turnSteps),
1572
+ turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1463
1573
  },
1464
1574
  }
1465
1575
  }