dsh-code 0.7.0 → 0.8.0

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 (44) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +2685 -622
  4. package/lib/types/app.d.ts +77 -1
  5. package/lib/types/history.d.ts +15 -4
  6. package/lib/types/index.d.ts +48 -0
  7. package/lib/types/kernel-panels.d.ts +7 -0
  8. package/lib/types/permissions.d.ts +37 -0
  9. package/lib/types/presets.d.ts +2 -0
  10. package/lib/types/provider-settings.d.ts +144 -0
  11. package/lib/types/questions.d.ts +2 -0
  12. package/lib/types/render/animations.d.ts +8 -6
  13. package/lib/types/render/lines.d.ts +6 -0
  14. package/lib/types/render/markdown.d.ts +3 -3
  15. package/lib/types/render/projection.d.ts +95 -3
  16. package/lib/types/render/status.d.ts +26 -36
  17. package/lib/types/render/text.d.ts +14 -7
  18. package/lib/types/render/tool-detail.d.ts +3 -1
  19. package/lib/types/render/tool-preview.d.ts +4 -1
  20. package/lib/types/session-directory.d.ts +15 -0
  21. package/lib/types/store.d.ts +13 -2
  22. package/lib/types/version.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/src/app.ts +847 -150
  25. package/src/approval.ts +11 -2
  26. package/src/history.ts +20 -5
  27. package/src/index.ts +402 -159
  28. package/src/kernel-panels.ts +45 -8
  29. package/src/permissions.ts +85 -0
  30. package/src/presets.ts +12 -0
  31. package/src/provider-settings.ts +520 -0
  32. package/src/questions.ts +15 -5
  33. package/src/render/animations.ts +32 -18
  34. package/src/render/lines.ts +21 -6
  35. package/src/render/markdown.ts +302 -4
  36. package/src/render/projection.ts +665 -10
  37. package/src/render/status.ts +68 -162
  38. package/src/render/text.ts +28 -9
  39. package/src/render/tool-detail.ts +81 -40
  40. package/src/render/tool-preview.ts +18 -2
  41. package/src/session-directory.ts +44 -5
  42. package/src/skills.ts +8 -4
  43. package/src/store.ts +26 -8
  44. package/src/version.ts +16 -0
@@ -28,6 +28,16 @@ import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
28
28
  /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
29
29
  const MAX_STREAMING_CHARS = 65_536
30
30
 
31
+ /**
32
+ * Upper bound on remembered `compaction/summary` shadow prices waiting for a
33
+ * matching `compaction/end`. Compactions are sequential and rare, so a few
34
+ * slots suffice; an aborted compaction (summary without end) otherwise leaves
35
+ * an unbounded residue in `anchors.compactionTokens`. An evicted price
36
+ * degrades to the documented `lastPruneTokens` fallback, exactly like a
37
+ * missing summary.
38
+ */
39
+ const MAX_COMPACTION_SUMMARY_RESIDUE = 16
40
+
31
41
  /** Append one delta without retaining an unbounded duplicate of the live reply. */
32
42
  function appendStreamingTail(current: string, delta: string): string {
33
43
  const next = current + delta
@@ -273,9 +283,21 @@ export interface TranscriptView {
273
283
  /**
274
284
  * Fold-internal timing anchors, never rendered: open step and tool-call
275
285
  * start timestamps the next `assistant/message` / `tool/result` resolves
276
- * against. Keyed `turn:step` and by call id.
286
+ * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
287
+ * track which step/tool anchors still belong to the open turn so
288
+ * `turn/end` (and a superseding `step/start`) can sweep anchors an
289
+ * interruption left behind; `turnFiles` keys mutated paths by turn.
277
290
  */
278
- readonly anchors: { stepStart: Map<string, number>; toolStart: Map<string, number>; firstChunkAt: Map<string, number>; compactionTokens: Map<string, number>; lastPruneTokens: number; turnFiles: Map<number, Set<string>> }
291
+ readonly anchors: {
292
+ stepStart: Map<string, number>
293
+ toolStart: Map<string, number>
294
+ firstChunkAt: Map<string, number>
295
+ compactionTokens: Map<string, number>
296
+ lastPruneTokens: number
297
+ turnFiles: Map<number, Set<string>>
298
+ turnSteps: Map<number, string>
299
+ turnTools: Map<number, Set<string>>
300
+ }
279
301
  }
280
302
 
281
303
  /** Join the text blocks of a content list; non-text blocks contribute nothing. */
@@ -323,7 +345,7 @@ export function createTranscriptView(): TranscriptView {
323
345
  goal: undefined,
324
346
  pending: { 'next-turn': [], 'next-step': [] },
325
347
  stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
326
- anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map() },
348
+ anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map() },
327
349
  }
328
350
  }
329
351
 
@@ -449,6 +471,9 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
449
471
  view.anchors.stepStart.delete(key)
450
472
  const firstChunk = view.anchors.firstChunkAt.get(key)
451
473
  view.anchors.firstChunkAt.delete(key)
474
+ // The assembled message consumes the turn's current step anchor; a
475
+ // later `turn/end` sweep then has nothing left to clean for this step.
476
+ if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
452
477
  const usage = event.data.usage
453
478
  const totals = view.stats.usage
454
479
  const text = textOf(event.data.message.content)
@@ -487,6 +512,11 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
487
512
  case 'tool/call': {
488
513
  const data = event.data
489
514
  view.anchors.toolStart.set(data.callId, event.time)
515
+ // Remember the call's turn so `turn/end` can sweep a start that never
516
+ // pairs with a result (an interrupted tool otherwise leaks its anchor).
517
+ const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
518
+ turnTools.add(data.callId)
519
+ view.anchors.turnTools.set(data.turn, turnTools)
490
520
  return {
491
521
  ...view,
492
522
  entries: [...view.entries, {
@@ -513,6 +543,13 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
513
543
  const block = event.data.message.content[0]
514
544
  const started = view.anchors.toolStart.get(block.toolCallId)
515
545
  view.anchors.toolStart.delete(block.toolCallId)
546
+ // Deregister the call from its turn's registry so `turn/end` does not
547
+ // sweep a start that already paired with a result.
548
+ const turnTools = view.anchors.turnTools.get(event.data.turn)
549
+ if (turnTools !== undefined) {
550
+ turnTools.delete(block.toolCallId)
551
+ if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
552
+ }
516
553
  const rawText = textOf(block.content)
517
554
  const summary = boundContextSummary(rawText)
518
555
  // The verbose expansion self-serves from the persisted presentation
@@ -555,14 +592,28 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
555
592
  todos: [],
556
593
  stats: { ...view.stats, turns: view.stats.turns + 1 },
557
594
  }
558
- case 'step/start':
559
- view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time)
595
+ case 'step/start': {
596
+ // A step supersedes the turn's previous step: if that step never
597
+ // assembled a message (interrupted), its timing anchors are stale the
598
+ // moment the next step opens and are swept here instead of leaking.
599
+ const key = `${event.data.turn}:${event.data.step}`
600
+ const previous = view.anchors.turnSteps.get(event.data.turn)
601
+ if (previous !== undefined && previous !== key) {
602
+ view.anchors.stepStart.delete(previous)
603
+ view.anchors.firstChunkAt.delete(previous)
604
+ }
605
+ view.anchors.turnSteps.set(event.data.turn, key)
606
+ view.anchors.stepStart.set(key, event.time)
560
607
  return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
608
+ }
561
609
  case 'turn/end': {
562
610
  const reason = event.data.reason
563
611
  const appended: TranscriptEntry[] = []
564
612
  if (reason.kind === 'error') {
565
- appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}` })
613
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
614
+ ? ' · open /model to add an API key'
615
+ : ''
616
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
566
617
  } else {
567
618
  // Non-error outcomes deserve their own durable row (the web renders
568
619
  // distinct max-tokens / abort / interruption nodes); `completed` stays
@@ -583,6 +634,20 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
583
634
  const files = view.anchors.turnFiles.get(event.data.turn)
584
635
  view.anchors.turnFiles.delete(event.data.turn)
585
636
  if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
637
+ // Derivable boundary sweep: the turn is over, so any step/tool anchors
638
+ // it left behind (interruptions that never produced their message or
639
+ // result) can never be resolved and are reclaimed now.
640
+ const stepKey = view.anchors.turnSteps.get(event.data.turn)
641
+ if (stepKey !== undefined) {
642
+ view.anchors.stepStart.delete(stepKey)
643
+ view.anchors.firstChunkAt.delete(stepKey)
644
+ view.anchors.turnSteps.delete(event.data.turn)
645
+ }
646
+ const turnToolSet = view.anchors.turnTools.get(event.data.turn)
647
+ if (turnToolSet !== undefined) {
648
+ for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
649
+ view.anchors.turnTools.delete(event.data.turn)
650
+ }
586
651
  if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
587
652
  return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
588
653
  }
@@ -651,7 +716,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
651
716
  return { ...view, title: event.data.title }
652
717
  case 'compaction/summary':
653
718
  // Remember the shadow price so the matching `compaction/end` row can
654
- // state what the compaction reclaimed.
719
+ // state what the compaction reclaimed. The map is capped so an aborted
720
+ // compaction (summary without end) cannot leave an unbounded residue.
721
+ if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
722
+ const oldest = view.anchors.compactionTokens.keys().next().value
723
+ if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
724
+ }
655
725
  view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
656
726
  return view
657
727
  case 'compaction/prune':
@@ -730,13 +800,594 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
730
800
  }
731
801
  }
732
802
 
803
+ /**
804
+ * Mutable replay accumulator: folds a persisted log into the identical view
805
+ * `projectEvent` would produce, but in near-linear time. Where `projectEvent`
806
+ * is copy-on-write — every append/scan rebuilds the whole `entries` array, so
807
+ * folding a full log costs O(N²) — the accumulator appends by push, resolves
808
+ * id-keyed updates (tool/result, command/done, retry-started) through index
809
+ * maps, and tombstones retired pending rows, so the whole log folds in O(N)
810
+ * plus one compaction pass when tombstones exist.
811
+ *
812
+ * Index maps never delete: every appended row registers its index, so an id
813
+ * lookup miss provably means no matching row exists and the update is an O(1)
814
+ * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
815
+ * scans). Each id maps to ALL of its indices, so a duplicate id updates every
816
+ * matching row exactly like the copy-on-write reducer.
817
+ *
818
+ * @internal Exported only so tests can (a) prove replay ≡ sequential
819
+ * `projectEvent` folds and (b) assert the linear complexity deterministically
820
+ * via {@link ReplayAccumulator.ops}, which counts entry-level container work
821
+ * instead of relying on wall-clock thresholds. No public consumer.
822
+ */
823
+ export interface ReplayAccumulator {
824
+ /** Working entry list; `undefined` marks a retired pending row (tombstone). */
825
+ entries: (TranscriptEntry | undefined)[]
826
+ /** callId → every index into `entries` holding a `tool` row with that id. */
827
+ toolIndex: Map<string, number[]>
828
+ /** commandId → every index into `entries` holding a `command` row with that id. */
829
+ commandIndex: Map<string, number[]>
830
+ /** retryId → every index into `entries` holding a `retry` row with that id. */
831
+ retryIndex: Map<string, number[]>
832
+ /** messageId → every index into `entries` holding a `pending` row with that id. */
833
+ pendingIndex: Map<string, number[]>
834
+ /** Tombstone count; zero means `entries` is already the final array. */
835
+ removedCount: number
836
+ /** Mutable inbox id lists, mirroring `view.pending` order per target. */
837
+ pendingTurn: string[]
838
+ pendingStep: string[]
839
+ streaming: string
840
+ streamingReasoning: string
841
+ todos: readonly TodoItem[]
842
+ busy: boolean
843
+ busySince: number
844
+ model: string
845
+ plan: boolean
846
+ permission: string
847
+ title: string
848
+ sandbox: string
849
+ goal: GoalFold | undefined
850
+ stats: TranscriptStats
851
+ stepStart: Map<string, number>
852
+ toolStart: Map<string, number>
853
+ firstChunkAt: Map<string, number>
854
+ compactionTokens: Map<string, number>
855
+ lastPruneTokens: number
856
+ turnFiles: Map<number, Set<string>>
857
+ turnSteps: Map<number, string>
858
+ turnTools: Map<number, Set<string>>
859
+ /** Entry-level container operations performed so far (test instrumentation). */
860
+ ops: number
861
+ }
862
+
863
+ /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
864
+ export function createReplayAccumulator(): ReplayAccumulator {
865
+ return {
866
+ entries: [],
867
+ toolIndex: new Map(),
868
+ commandIndex: new Map(),
869
+ retryIndex: new Map(),
870
+ pendingIndex: new Map(),
871
+ removedCount: 0,
872
+ pendingTurn: [],
873
+ pendingStep: [],
874
+ streaming: '',
875
+ streamingReasoning: '',
876
+ todos: [],
877
+ busy: false,
878
+ busySince: 0,
879
+ model: '',
880
+ plan: false,
881
+ permission: '',
882
+ title: '',
883
+ sandbox: '',
884
+ goal: undefined,
885
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
886
+ stepStart: new Map(),
887
+ toolStart: new Map(),
888
+ firstChunkAt: new Map(),
889
+ compactionTokens: new Map(),
890
+ lastPruneTokens: 0,
891
+ turnFiles: new Map(),
892
+ turnSteps: new Map(),
893
+ turnTools: new Map(),
894
+ ops: 0,
895
+ }
896
+ }
897
+
898
+ /** Append one entry (O(1)) and account the push. */
899
+ function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
900
+ acc.entries.push(entry)
901
+ acc.ops += 1
902
+ }
903
+
904
+ /**
905
+ * Get (or create) the index list an id owns. Lists are never removed: every
906
+ * appended row registers its index, so a lookup miss later proves no matching
907
+ * row exists and the caller can no-op in O(1).
908
+ */
909
+ function indexList(map: Map<string, number[]>, id: string): number[] {
910
+ let list = map.get(id)
911
+ if (list === undefined) {
912
+ list = []
913
+ map.set(id, list)
914
+ }
915
+ return list
916
+ }
917
+
918
+ /**
919
+ * Apply an id-keyed update to every row that registered the id, mirroring the
920
+ * copy-on-write reducer's full-array map semantics (all matching rows update,
921
+ * in order). Each registered index is O(1), so a duplicate id costs
922
+ * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
923
+ * registered indices are valid by construction, because tool/command/retry
924
+ * rows are never removed and tombstones never shift indices.
925
+ */
926
+ function updateReplayById<T extends TranscriptEntry>(
927
+ acc: ReplayAccumulator,
928
+ map: Map<string, number[]>,
929
+ id: string,
930
+ isMatch: (entry: T) => boolean,
931
+ update: (entry: T) => T,
932
+ ): void {
933
+ const list = map.get(id)
934
+ if (list === undefined) return // miss provably means no matching row
935
+ for (const index of list) {
936
+ const entry = acc.entries[index]
937
+ if (entry === undefined || !isMatch(entry as T)) continue
938
+ acc.entries[index] = update(entry as T)
939
+ acc.ops += 1
940
+ }
941
+ }
942
+
943
+ /** Tombstone a retired pending row, keeping every other index stable. */
944
+ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
945
+ if (acc.entries[index] !== undefined) {
946
+ acc.entries[index] = undefined
947
+ acc.removedCount += 1
948
+ acc.ops += 1
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Fold one session event into a replay accumulator. This mirrors
954
+ * {@link projectEvent} case for case — same stats arithmetic, same anchor
955
+ * set/delete behavior, same entry shapes — so the finished view is identical
956
+ * to a sequential fold; only the `entries` container operations are mutable.
957
+ *
958
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
959
+ */
960
+ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): void {
961
+ switch (event.type) {
962
+ case 'user/message': {
963
+ const message = event.data
964
+ for (const target of ['next-turn', 'next-step'] as const) {
965
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
966
+ const index = ids.indexOf(message.id)
967
+ acc.ops += index < 0 ? ids.length : index + 1
968
+ if (index < 0) continue
969
+ ids.splice(index, 1)
970
+ acc.ops += 1
971
+ // Retire every pending row carrying this message id (duplicate ids
972
+ // included), exactly like the reducer's full-array filter.
973
+ const list = acc.pendingIndex.get(message.id)
974
+ if (list !== undefined) {
975
+ for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
976
+ acc.ops += 1
977
+ }
978
+ }
979
+ const text = textOf(message.content)
980
+ if (message.source.kind === 'user') {
981
+ appendReplayEntry(acc, { kind: 'user', text, notice: false })
982
+ acc.stats = {
983
+ ...acc.stats,
984
+ contextSegments: {
985
+ ...acc.stats.contextSegments,
986
+ prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
987
+ },
988
+ }
989
+ return
990
+ }
991
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
992
+ ? message.source.summary
993
+ : message.source.kind
994
+ const summary = boundContextSummary(notice)
995
+ appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
996
+ acc.stats = {
997
+ ...acc.stats,
998
+ contextSegments: {
999
+ ...acc.stats.contextSegments,
1000
+ system: acc.stats.contextSegments.system + estimateTokens(summary),
1001
+ },
1002
+ }
1003
+ return
1004
+ }
1005
+ case 'agent/inbox/spliced': {
1006
+ const { target, start, removedCount = 0, inserted } = event.data
1007
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1008
+ const removed = ids.slice(start, start + removedCount)
1009
+ acc.ops += removed.length
1010
+ ids.splice(start, removedCount)
1011
+ acc.ops += removed.length
1012
+ for (const id of removed) {
1013
+ const list = acc.pendingIndex.get(id)
1014
+ if (list === undefined) continue
1015
+ for (const entryIndex of list) {
1016
+ const entry = acc.entries[entryIndex]
1017
+ if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1018
+ retireReplayEntry(acc, entryIndex)
1019
+ }
1020
+ }
1021
+ }
1022
+ for (const message of inserted) {
1023
+ appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content) })
1024
+ indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1025
+ ids.push(message.id)
1026
+ acc.ops += 1
1027
+ }
1028
+ return
1029
+ }
1030
+ case 'assistant/chunk': {
1031
+ const chunk = event.data.chunk
1032
+ const key = `${event.data.turn}:${event.data.step}`
1033
+ const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
1034
+ if (delta !== '' && !acc.firstChunkAt.has(key)) {
1035
+ acc.firstChunkAt.set(key, event.time)
1036
+ const started = acc.stepStart.get(key)
1037
+ if (started !== undefined) {
1038
+ acc.stats = {
1039
+ ...acc.stats,
1040
+ ttftMs: acc.stats.ttftMs + Math.max(0, event.time - started),
1041
+ ttftSteps: acc.stats.ttftSteps + 1,
1042
+ }
1043
+ }
1044
+ }
1045
+ if (chunk.type === 'text-delta') {
1046
+ acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1047
+ return
1048
+ }
1049
+ if (chunk.type === 'reasoning-delta') {
1050
+ acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1051
+ return
1052
+ }
1053
+ return
1054
+ }
1055
+ case 'assistant/message': {
1056
+ const key = `${event.data.turn}:${event.data.step}`
1057
+ const started = acc.stepStart.get(key)
1058
+ acc.stepStart.delete(key)
1059
+ const firstChunk = acc.firstChunkAt.get(key)
1060
+ acc.firstChunkAt.delete(key)
1061
+ if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
1062
+ const usage = event.data.usage
1063
+ const totals = acc.stats.usage
1064
+ const text = textOf(event.data.message.content)
1065
+ const reasoning = reasoningOf(event.data.message.content)
1066
+ acc.streaming = ''
1067
+ acc.streamingReasoning = ''
1068
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning })
1069
+ acc.stats = {
1070
+ ...acc.stats,
1071
+ llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1072
+ usage: usage === undefined ? totals : {
1073
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1074
+ outputTokens: totals.outputTokens + usage.outputTokens,
1075
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1076
+ },
1077
+ lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1078
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1079
+ decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1080
+ decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1081
+ contextSegments: {
1082
+ ...acc.stats.contextSegments,
1083
+ thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1084
+ assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1085
+ },
1086
+ }
1087
+ return
1088
+ }
1089
+ case 'tool/call': {
1090
+ const data = event.data
1091
+ acc.toolStart.set(data.callId, event.time)
1092
+ const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1093
+ turnTools.add(data.callId)
1094
+ acc.turnTools.set(data.turn, turnTools)
1095
+ appendReplayEntry(acc, {
1096
+ kind: 'tool',
1097
+ callId: data.callId,
1098
+ name: data.name,
1099
+ arguments: data.arguments,
1100
+ preview: toolArgumentsPreview(data.arguments, data.name),
1101
+ state: 'running',
1102
+ summary: '',
1103
+ detail: undefined,
1104
+ })
1105
+ indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1106
+ acc.stats = {
1107
+ ...acc.stats,
1108
+ contextSegments: {
1109
+ ...acc.stats.contextSegments,
1110
+ tools: acc.stats.contextSegments.tools
1111
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1112
+ },
1113
+ }
1114
+ return
1115
+ }
1116
+ case 'tool/result': {
1117
+ const block = event.data.message.content[0]
1118
+ const started = acc.toolStart.get(block.toolCallId)
1119
+ acc.toolStart.delete(block.toolCallId)
1120
+ const turnTools = acc.turnTools.get(event.data.turn)
1121
+ if (turnTools !== undefined) {
1122
+ turnTools.delete(block.toolCallId)
1123
+ if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
1124
+ }
1125
+ const rawText = textOf(block.content)
1126
+ const summary = boundContextSummary(rawText)
1127
+ const detail = toolResultDetail(event.data.meta, rawText)
1128
+ if (detail?.kind === 'diff') {
1129
+ const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
1130
+ for (const diff of detail.diffs) set.add(diff.path)
1131
+ acc.turnFiles.set(event.data.turn, set)
1132
+ }
1133
+ const update = (entry: ToolEntry): ToolEntry => ({
1134
+ ...entry,
1135
+ state: block.isError === true ? 'error' as const : 'done' as const,
1136
+ summary,
1137
+ detail,
1138
+ })
1139
+ // Every matching row updates (duplicate callIds included); an id with no
1140
+ // registered index is a provable no-op — no full-array fallback scan.
1141
+ updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
1142
+ acc.stats = {
1143
+ ...acc.stats,
1144
+ toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1145
+ contextSegments: {
1146
+ ...acc.stats.contextSegments,
1147
+ tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1148
+ },
1149
+ }
1150
+ return
1151
+ }
1152
+ case 'todo/write':
1153
+ acc.todos = event.data.todos
1154
+ return
1155
+ case 'turn/start': {
1156
+ const wasBusy = acc.busy
1157
+ acc.busy = true
1158
+ acc.busySince = wasBusy ? acc.busySince : event.time
1159
+ acc.todos = []
1160
+ acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1161
+ return
1162
+ }
1163
+ case 'step/start': {
1164
+ const key = `${event.data.turn}:${event.data.step}`
1165
+ const previous = acc.turnSteps.get(event.data.turn)
1166
+ if (previous !== undefined && previous !== key) {
1167
+ acc.stepStart.delete(previous)
1168
+ acc.firstChunkAt.delete(previous)
1169
+ }
1170
+ acc.turnSteps.set(event.data.turn, key)
1171
+ acc.stepStart.set(key, event.time)
1172
+ acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1173
+ return
1174
+ }
1175
+ case 'turn/end': {
1176
+ const reason = event.data.reason
1177
+ const appended: TranscriptEntry[] = []
1178
+ if (reason.kind === 'error') {
1179
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1180
+ ? ' · open /model to add an API key'
1181
+ : ''
1182
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
1183
+ } else {
1184
+ const marker = reason.kind === 'aborted'
1185
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
1186
+ : reason.kind === 'max-tokens'
1187
+ ? 'turn hit the output-token ceiling (max-tokens)'
1188
+ : reason.kind === 'blocked'
1189
+ ? 'turn ended blocked'
1190
+ : reason.kind === 'interrupted'
1191
+ ? 'turn was interrupted by a restart'
1192
+ : undefined
1193
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
1194
+ }
1195
+ const files = acc.turnFiles.get(event.data.turn)
1196
+ acc.turnFiles.delete(event.data.turn)
1197
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
1198
+ const stepKey = acc.turnSteps.get(event.data.turn)
1199
+ if (stepKey !== undefined) {
1200
+ acc.stepStart.delete(stepKey)
1201
+ acc.firstChunkAt.delete(stepKey)
1202
+ acc.turnSteps.delete(event.data.turn)
1203
+ }
1204
+ const turnToolSet = acc.turnTools.get(event.data.turn)
1205
+ if (turnToolSet !== undefined) {
1206
+ for (const callId of turnToolSet) acc.toolStart.delete(callId)
1207
+ acc.turnTools.delete(event.data.turn)
1208
+ }
1209
+ acc.busy = false
1210
+ acc.busySince = 0
1211
+ for (const entry of appended) appendReplayEntry(acc, entry)
1212
+ return
1213
+ }
1214
+ case 'llm/retry': {
1215
+ const data = event.data
1216
+ appendReplayEntry(acc, {
1217
+ kind: 'retry',
1218
+ retryId: data.retryId,
1219
+ attempt: data.retry,
1220
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
1221
+ code: data.failure.code,
1222
+ delayMs: data.delayMs,
1223
+ state: 'running',
1224
+ })
1225
+ indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1226
+ return
1227
+ }
1228
+ case 'llm/retry-started': {
1229
+ const data = event.data
1230
+ updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1231
+ return
1232
+ }
1233
+ case 'sandbox/mode':
1234
+ acc.sandbox = event.data.mode
1235
+ return
1236
+ case 'goal/change': {
1237
+ const data = event.data
1238
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1239
+ if (data.operation === 'clear') {
1240
+ acc.goal = undefined
1241
+ appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1242
+ return
1243
+ }
1244
+ const goal: GoalFold = {
1245
+ objective: data.goal.objective,
1246
+ phase: data.goal.phase,
1247
+ rounds: data.roundsStarted,
1248
+ max: data.goal.maxGoalRounds,
1249
+ blocked: data.goal.blockedReason?.message ?? '',
1250
+ }
1251
+ const line = data.operation === 'create'
1252
+ ? `◎ goal: ${clip(data.goal.objective)}`
1253
+ : data.operation === 'complete'
1254
+ ? '◎ goal complete'
1255
+ : data.operation === 'pause'
1256
+ ? '◎ goal paused'
1257
+ : data.operation === 'resume'
1258
+ ? '◎ goal resumed'
1259
+ : data.operation === 'block'
1260
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
1261
+ : undefined
1262
+ acc.goal = goal
1263
+ if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1264
+ return
1265
+ }
1266
+ case 'session/title':
1267
+ acc.title = event.data.title
1268
+ return
1269
+ case 'compaction/summary':
1270
+ if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1271
+ const oldest = acc.compactionTokens.keys().next().value
1272
+ if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1273
+ }
1274
+ acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1275
+ return
1276
+ case 'compaction/prune':
1277
+ acc.lastPruneTokens = event.data.shadowedTokenCount
1278
+ return
1279
+ case 'compaction/end': {
1280
+ const ok = event.data.error === undefined
1281
+ const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1282
+ acc.compactionTokens.delete(event.data.compactionId)
1283
+ appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1284
+ return
1285
+ }
1286
+ case 'request/context':
1287
+ acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1288
+ return
1289
+ case 'request/header': {
1290
+ const config = event.data.header.config
1291
+ acc.model = `${config.provider}/${config.model}`
1292
+ acc.stats = {
1293
+ ...acc.stats,
1294
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1295
+ contextSegments: {
1296
+ ...acc.stats.contextSegments,
1297
+ system: estimateTokens(event.data.header.system ?? ''),
1298
+ },
1299
+ }
1300
+ return
1301
+ }
1302
+ case 'plan/mode':
1303
+ acc.plan = event.data.active
1304
+ return
1305
+ case 'permission/preset':
1306
+ acc.permission = event.data.preset
1307
+ return
1308
+ case 'command/run': {
1309
+ const data = event.data
1310
+ appendReplayEntry(acc, {
1311
+ kind: 'command',
1312
+ commandId: data.commandId,
1313
+ name: data.name,
1314
+ args: data.args ?? '',
1315
+ state: 'running',
1316
+ summary: '',
1317
+ })
1318
+ indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1319
+ return
1320
+ }
1321
+ case 'command/done': {
1322
+ const data = event.data
1323
+ const update = (candidate: CommandEntry): CommandEntry => ({
1324
+ ...candidate,
1325
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
1326
+ summary: boundContextSummary(data.text ?? ''),
1327
+ })
1328
+ updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1329
+ return
1330
+ }
1331
+ default:
1332
+ return
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * Materialize the accumulated fold as a `TranscriptView`, compacting any
1338
+ * retired tombstones. The anchors maps are handed through as-is (their
1339
+ * content is identical to a sequential fold's).
1340
+ *
1341
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1342
+ */
1343
+ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1344
+ const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1345
+ ? acc.entries as TranscriptEntry[]
1346
+ : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1347
+ if (acc.removedCount > 0) acc.ops += acc.entries.length
1348
+ return {
1349
+ entries,
1350
+ streaming: acc.streaming,
1351
+ streamingReasoning: acc.streamingReasoning,
1352
+ todos: acc.todos,
1353
+ busy: acc.busy,
1354
+ busySince: acc.busySince,
1355
+ model: acc.model,
1356
+ plan: acc.plan,
1357
+ permission: acc.permission,
1358
+ title: acc.title,
1359
+ sandbox: acc.sandbox,
1360
+ goal: acc.goal,
1361
+ pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1362
+ stats: acc.stats,
1363
+ anchors: {
1364
+ stepStart: acc.stepStart,
1365
+ toolStart: acc.toolStart,
1366
+ firstChunkAt: acc.firstChunkAt,
1367
+ compactionTokens: acc.compactionTokens,
1368
+ lastPruneTokens: acc.lastPruneTokens,
1369
+ turnFiles: acc.turnFiles,
1370
+ turnSteps: acc.turnSteps,
1371
+ turnTools: acc.turnTools,
1372
+ },
1373
+ }
1374
+ }
1375
+
733
1376
  /**
734
1377
  * Fold a replayed event history into one view.
1378
+ *
1379
+ * Folding is near-linear in the log size: the mutable replay accumulator
1380
+ * appends in place and resolves id-keyed updates through index maps, so a
1381
+ * long persisted session replays without the O(N²) copy-on-write rebuilds a
1382
+ * naive sequential fold would incur. The result is identical to folding
1383
+ * {@link projectEvent} per event in order.
735
1384
  * @param events - events in `seq` order.
736
1385
  * @returns the folded view.
737
1386
  */
738
1387
  export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
739
- return events.reduce(projectEvent, createTranscriptView())
1388
+ const acc = createReplayAccumulator()
1389
+ for (const event of events) replayProjectEvent(acc, event)
1390
+ return finishReplay(acc)
740
1391
  }
741
1392
 
742
1393
  /**
@@ -748,8 +1399,11 @@ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
748
1399
  * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
749
1400
  * `user/message` retirement), and an append-only `<Static>` flush cannot
750
1401
  * erase a row that vanishes from the view — the retired row would ghost on
751
- * screen until the next source-backed replay. Everything else (including a
752
- * completed tail) is final: later events only APPEND new rows.
1402
+ * screen until the next source-backed replay. Running commands join the
1403
+ * mutable boundary for the same reason in reverse: `command/done` mutates the
1404
+ * row's state/summary, so a flushed row would keep its stale running mark
1405
+ * until a resize-triggered replay. Everything else (including a completed
1406
+ * tail) is final: later events only APPEND new rows.
753
1407
  * @param entries - the view's transcript entries in order.
754
1408
  * @returns the count of entries safe to flush (0 for an empty transcript).
755
1409
  */
@@ -759,6 +1413,7 @@ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
759
1413
  if (entry.kind === 'pending') return index
760
1414
  if (entry.kind === 'tool' && entry.state === 'running') return index
761
1415
  if (entry.kind === 'retry' && entry.state === 'running') return index
1416
+ if (entry.kind === 'command' && entry.state === 'running') return index
762
1417
  }
763
1418
  return entries.length
764
1419
  }