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