@toddzheng024/dscode-bundle 0.7.11 → 0.7.13

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 (154) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -3
  2. package/cordis.patch.yml +1 -1
  3. package/package.json +5 -4
  4. package/plugins/i18n/messages.mjs +6 -6
  5. package/plugins/session-metrics/view.mjs +16 -5
  6. package/plugins/tui-tools/index.mjs +14 -1
  7. package/vendor/tui/lib/app.mjs +6674 -0
  8. package/vendor/tui/lib/approval.mjs +122 -0
  9. package/vendor/tui/lib/attachments.mjs +218 -0
  10. package/vendor/tui/lib/authorization-panel.mjs +242 -0
  11. package/vendor/tui/lib/authorization.mjs +111 -0
  12. package/vendor/tui/lib/commands.mjs +123 -0
  13. package/vendor/tui/lib/dscode/chat.mjs +64 -0
  14. package/vendor/tui/lib/dscode/flags.mjs +32 -0
  15. package/vendor/tui/lib/dscode/model-search.mjs +68 -0
  16. package/vendor/tui/lib/dscode/paste.mjs +87 -0
  17. package/vendor/tui/lib/dscode/telemetry.mjs +82 -0
  18. package/vendor/tui/lib/dscode/welcome.mjs +100 -0
  19. package/vendor/tui/lib/editor-keys.mjs +346 -0
  20. package/vendor/tui/lib/editor.mjs +50 -0
  21. package/vendor/tui/lib/fork.mjs +24 -0
  22. package/vendor/tui/lib/git-workflow.mjs +278 -0
  23. package/vendor/tui/{types/history.d.ts → lib/history.mjs} +69 -26
  24. package/vendor/tui/{types/i18n.d.ts → lib/i18n.mjs} +23 -15
  25. package/vendor/tui/lib/index.mjs +2198 -0
  26. package/vendor/tui/lib/input-split.mjs +170 -0
  27. package/vendor/tui/lib/internals.mjs +90 -0
  28. package/vendor/tui/lib/invariant.mjs +22 -0
  29. package/vendor/tui/lib/kernel-panels.mjs +1265 -0
  30. package/vendor/tui/lib/keyboard.mjs +283 -0
  31. package/vendor/tui/lib/language-panel.mjs +41 -0
  32. package/vendor/tui/lib/locales/en.mjs +495 -0
  33. package/vendor/tui/lib/locales/zh.mjs +495 -0
  34. package/vendor/tui/lib/mentions.mjs +150 -0
  35. package/vendor/tui/lib/model-capabilities.mjs +245 -0
  36. package/vendor/tui/lib/models.mjs +215 -0
  37. package/vendor/tui/{types/panel-accent.d.ts → lib/panel-accent.mjs} +11 -7
  38. package/vendor/tui/lib/permissions.mjs +45 -0
  39. package/vendor/tui/lib/plugin-inventory.mjs +27 -0
  40. package/vendor/tui/lib/presets.mjs +46 -0
  41. package/vendor/tui/lib/provider-settings.mjs +600 -0
  42. package/vendor/tui/lib/questions.mjs +116 -0
  43. package/vendor/tui/lib/rainbow.mjs +181 -0
  44. package/vendor/tui/lib/render/animations.mjs +665 -0
  45. package/vendor/tui/lib/render/editor.mjs +437 -0
  46. package/vendor/tui/lib/render/export.mjs +119 -0
  47. package/vendor/tui/lib/render/fuzzy.mjs +79 -0
  48. package/vendor/tui/lib/render/ime-cursor.mjs +135 -0
  49. package/vendor/tui/lib/render/inspector.mjs +93 -0
  50. package/vendor/tui/lib/render/lines.mjs +538 -0
  51. package/vendor/tui/lib/render/markdown.mjs +592 -0
  52. package/vendor/tui/lib/render/projection.mjs +1862 -0
  53. package/vendor/tui/lib/render/status.mjs +591 -0
  54. package/vendor/tui/lib/render/text.mjs +157 -0
  55. package/vendor/tui/lib/render/tool-detail.mjs +187 -0
  56. package/vendor/tui/lib/render/tool-preview.mjs +79 -0
  57. package/vendor/tui/lib/render/usage.mjs +336 -0
  58. package/vendor/tui/lib/render/width.mjs +191 -0
  59. package/vendor/tui/lib/session-directory.mjs +329 -0
  60. package/vendor/tui/lib/session-query.mjs +184 -0
  61. package/vendor/tui/lib/session-switch.mjs +51 -0
  62. package/vendor/tui/lib/settings-file.mjs +75 -0
  63. package/vendor/tui/lib/skills.mjs +119 -0
  64. package/vendor/tui/lib/startup.mjs +111 -0
  65. package/vendor/tui/lib/store.mjs +129 -0
  66. package/vendor/tui/lib/subagents.mjs +200 -0
  67. package/vendor/tui/lib/terminal-title.mjs +186 -0
  68. package/vendor/tui/lib/theme-panel.mjs +60 -0
  69. package/vendor/tui/lib/theme.mjs +379 -0
  70. package/vendor/tui/lib/update-panel.mjs +239 -0
  71. package/vendor/tui/lib/update.mjs +101 -0
  72. package/vendor/tui/lib/version.mjs +105 -0
  73. package/vendor/tui/lib/whale-glyph.mjs +20 -0
  74. package/vendor/tui/LICENSE +0 -21
  75. package/vendor/tui/devtools-CdTl3MNy.mjs +0 -3643
  76. package/vendor/tui/dscode-email/cli.mjs +0 -32
  77. package/vendor/tui/dscode-email/contacts.mjs +0 -36
  78. package/vendor/tui/dscode-email/gmail-oauth.mjs +0 -69
  79. package/vendor/tui/dscode-email/gmail-store.mjs +0 -2
  80. package/vendor/tui/dscode-email/gmail.mjs +0 -194
  81. package/vendor/tui/dscode-email/imap.mjs +0 -136
  82. package/vendor/tui/dscode-email/inbox.d.mts +0 -25
  83. package/vendor/tui/dscode-email/inbox.mjs +0 -76
  84. package/vendor/tui/dscode-email/smtp.mjs +0 -69
  85. package/vendor/tui/dscode-email/store.mjs +0 -32
  86. package/vendor/tui/dscode-providers/catalog.mjs +0 -136
  87. package/vendor/tui/dscode-providers/effort.mjs +0 -35
  88. package/vendor/tui/dscode-providers/openrouter-account.mjs +0 -171
  89. package/vendor/tui/index.mjs +0 -44483
  90. package/vendor/tui/invariant.mjs +0 -21
  91. package/vendor/tui/rolldown-runtime-CMFfr-1z.mjs +0 -26
  92. package/vendor/tui/session-query.mjs +0 -150
  93. package/vendor/tui/startup.mjs +0 -109
  94. package/vendor/tui/theme-7u5Qo3dF.mjs +0 -1265
  95. package/vendor/tui/types/app.d.ts +0 -348
  96. package/vendor/tui/types/approval.d.ts +0 -59
  97. package/vendor/tui/types/attachments.d.ts +0 -52
  98. package/vendor/tui/types/authorization-panel.d.ts +0 -22
  99. package/vendor/tui/types/authorization.d.ts +0 -36
  100. package/vendor/tui/types/commands.d.ts +0 -52
  101. package/vendor/tui/types/editor-keys.d.ts +0 -105
  102. package/vendor/tui/types/editor.d.ts +0 -6
  103. package/vendor/tui/types/fork.d.ts +0 -8
  104. package/vendor/tui/types/git-workflow.d.ts +0 -121
  105. package/vendor/tui/types/index.d.ts +0 -223
  106. package/vendor/tui/types/input-split.d.ts +0 -54
  107. package/vendor/tui/types/internals.d.ts +0 -26
  108. package/vendor/tui/types/invariant.d.ts +0 -15
  109. package/vendor/tui/types/kernel-panels.d.ts +0 -245
  110. package/vendor/tui/types/keyboard.d.ts +0 -80
  111. package/vendor/tui/types/language-panel.d.ts +0 -12
  112. package/vendor/tui/types/locales/en.d.ts +0 -450
  113. package/vendor/tui/types/locales/zh.d.ts +0 -9
  114. package/vendor/tui/types/mentions.d.ts +0 -85
  115. package/vendor/tui/types/model-capabilities.d.ts +0 -82
  116. package/vendor/tui/types/models.d.ts +0 -133
  117. package/vendor/tui/types/permissions.d.ts +0 -27
  118. package/vendor/tui/types/plugin-inventory.d.ts +0 -11
  119. package/vendor/tui/types/presets.d.ts +0 -22
  120. package/vendor/tui/types/provider-settings.d.ts +0 -239
  121. package/vendor/tui/types/questions.d.ts +0 -54
  122. package/vendor/tui/types/rainbow.d.ts +0 -69
  123. package/vendor/tui/types/render/animations.d.ts +0 -307
  124. package/vendor/tui/types/render/editor.d.ts +0 -163
  125. package/vendor/tui/types/render/export.d.ts +0 -9
  126. package/vendor/tui/types/render/fuzzy.d.ts +0 -21
  127. package/vendor/tui/types/render/ime-cursor.d.ts +0 -60
  128. package/vendor/tui/types/render/inspector.d.ts +0 -62
  129. package/vendor/tui/types/render/lines.d.ts +0 -86
  130. package/vendor/tui/types/render/markdown.d.ts +0 -29
  131. package/vendor/tui/types/render/projection.d.ts +0 -587
  132. package/vendor/tui/types/render/status.d.ts +0 -196
  133. package/vendor/tui/types/render/text.d.ts +0 -64
  134. package/vendor/tui/types/render/tool-detail.d.ts +0 -94
  135. package/vendor/tui/types/render/tool-preview.d.ts +0 -28
  136. package/vendor/tui/types/render/usage.d.ts +0 -113
  137. package/vendor/tui/types/render/width.d.ts +0 -29
  138. package/vendor/tui/types/session-directory.d.ts +0 -190
  139. package/vendor/tui/types/session-query.d.ts +0 -92
  140. package/vendor/tui/types/session-switch.d.ts +0 -17
  141. package/vendor/tui/types/settings-file.d.ts +0 -41
  142. package/vendor/tui/types/skills.d.ts +0 -47
  143. package/vendor/tui/types/startup.d.ts +0 -65
  144. package/vendor/tui/types/store.d.ts +0 -58
  145. package/vendor/tui/types/subagents.d.ts +0 -70
  146. package/vendor/tui/types/terminal-title.d.ts +0 -66
  147. package/vendor/tui/types/theme-panel.d.ts +0 -24
  148. package/vendor/tui/types/theme.d.ts +0 -434
  149. package/vendor/tui/types/update-panel.d.ts +0 -49
  150. package/vendor/tui/types/update.d.ts +0 -75
  151. package/vendor/tui/types/version.d.ts +0 -19
  152. package/vendor/tui/types/whale-glyph.d.ts +0 -6
  153. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/clipboard-image.swift +0 -0
  154. /package/vendor/tui/{dscode-clipboard-image → lib/dscode/clipboard-image}/index.mjs +0 -0
@@ -0,0 +1,1862 @@
1
+ /**
2
+ * Pure session-event-to-view projection for the TUI transcript: one reducer
3
+ * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
+ * Rendering never reads the session directly — this module owns the view
5
+ * model, so tests drive it with plain event arrays.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/render/projection
8
+ */
9
+ import { assistantStreamFirstTokenTime, boundContextSummary, isTokenDelta } from '@deepseek-ai/dsh-llm';
10
+ import { graphemeWidth, splitGraphemes } from './width.mjs';
11
+ import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.mjs';
12
+ import { toolResultDetail } from './tool-detail.mjs';
13
+ /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
14
+ const MAX_STREAMING_CHARS = 65_536;
15
+ /**
16
+ * Upper bound on remembered `compaction/summary` shadow prices waiting for a
17
+ * matching `compaction/end`. Compactions are sequential and rare, so a few
18
+ * slots suffice; an aborted compaction (summary without end) otherwise leaves
19
+ * an unbounded residue in `anchors.compactionTokens`. An evicted price
20
+ * degrades to the documented `lastPruneTokens` fallback, exactly like a
21
+ * missing summary.
22
+ */
23
+ const MAX_COMPACTION_SUMMARY_RESIDUE = 16;
24
+ /** Append one delta without retaining an unbounded duplicate of the live reply. */
25
+ function appendStreamingTail(current, delta) {
26
+ const next = current + delta;
27
+ return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS);
28
+ }
29
+ /** Bounded sub-dispatch window per tool card (display budget only). */
30
+ export const MAX_TOOL_SUB_DISPATCHES = 12;
31
+ /** Bounded member window per workflow run card (display budget only). */
32
+ export const MAX_WORKFLOW_MEMBERS = 12;
33
+ /** Append one member to a run entry, evicting past the bounded window. */
34
+ function pushWorkflowMember(entry, member) {
35
+ if (entry.members.length >= MAX_WORKFLOW_MEMBERS) {
36
+ return { ...entry, members: [...entry.members.slice(1), member], membersDropped: entry.membersDropped + 1 };
37
+ }
38
+ return { ...entry, members: [...entry.members, member] };
39
+ }
40
+ /** Settle one member by its sequence number (pure; unmatched stays a no-op). */
41
+ function settleWorkflowMember(entry, data) {
42
+ let paired = false;
43
+ const members = entry.members.map(member => {
44
+ if (member.seq !== data.seq)
45
+ return member;
46
+ paired = true;
47
+ return { ...member, outcome: data.outcome };
48
+ });
49
+ return paired ? { ...entry, members } : entry;
50
+ }
51
+ /** JSON-stringify one sub-dispatch argument value for the preview helper. */
52
+ function subDispatchArguments(arguments_) {
53
+ if (typeof arguments_ === 'string')
54
+ return arguments_;
55
+ try {
56
+ return JSON.stringify(arguments_) ?? '';
57
+ }
58
+ catch {
59
+ return '';
60
+ }
61
+ }
62
+ /** Append one running sub-dispatch under its parent, evicting past the cap. */
63
+ function pushSubDispatch(entry, sub) {
64
+ if (entry.subs.length >= MAX_TOOL_SUB_DISPATCHES) {
65
+ return { ...entry, subs: [...entry.subs.slice(1), sub], subsDropped: entry.subsDropped + 1 };
66
+ }
67
+ return { ...entry, subs: [...entry.subs, sub] };
68
+ }
69
+ /** Fold one settled sub-dispatch into its matching row (pure). */
70
+ function settleSubDispatch(entry, data, summary, durationMs) {
71
+ let paired = false;
72
+ const subs = entry.subs.map(sub => {
73
+ if (sub.subCallId !== data.subCallId)
74
+ return sub;
75
+ paired = true;
76
+ return { ...sub, state: data.isError === true ? 'error' : 'done', summary, durationMs };
77
+ });
78
+ // A settle without a live row (the sub left the bounded window, or the
79
+ // parent entry was never seen) stays a provable no-op.
80
+ return paired ? { ...entry, subs } : entry;
81
+ }
82
+ /*
83
+ * Upstream record semantics (dsh-schedule types): `scheduledAt` is ALREADY
84
+ * the due instant — AfterScheduleRecord carries the RFC 3339 UTC target
85
+ * (delay included), EveryScheduleRecord carries the earliest anchor-aligned
86
+ * occurrence not yet dispatched. No kind ever adds its own interval on top.
87
+ */
88
+ /** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
89
+ export function applyScheduleChange(rows, data) {
90
+ if (data.operation === 'create' && data.schedule !== undefined) {
91
+ const schedule = data.schedule;
92
+ const row = {
93
+ id: schedule.id,
94
+ kind: schedule.kind,
95
+ prompt: schedule.prompt,
96
+ targetAt: Date.parse(schedule.scheduledAt),
97
+ ...(schedule.kind === 'every' ? { everySeconds: schedule.everySeconds ?? 0 } : {}),
98
+ };
99
+ return [...rows.filter(existing => existing.id !== schedule.id), row];
100
+ }
101
+ if (data.operation === 'delete' && data.id !== undefined) {
102
+ return rows.filter(existing => existing.id !== data.id);
103
+ }
104
+ if (data.operation === 'dispatch' && data.id !== undefined) {
105
+ // A dispatched one-shot reminder is finished. An 'every' reminder
106
+ // advances PAST every missed occurrence in one step: the next target is
107
+ // the first anchor-aligned instant strictly after acceptedAt, stepping
108
+ // from the previous aligned target (upstream advances the same way).
109
+ if (data.acceptedAt === undefined)
110
+ return rows.filter(existing => existing.id !== data.id);
111
+ const accepted = Date.parse(data.acceptedAt);
112
+ return rows.map(existing => existing.id === data.id
113
+ ? { ...existing, targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0) }
114
+ : existing);
115
+ }
116
+ return rows;
117
+ }
118
+ /** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
119
+ export function nextEveryTarget(previousTarget, acceptedAt, everySeconds) {
120
+ const interval = Math.max(1, everySeconds) * 1000;
121
+ if (acceptedAt <= previousTarget)
122
+ return previousTarget + interval;
123
+ const missed = Math.ceil((acceptedAt - previousTarget + 1) / interval);
124
+ return previousTarget + missed * interval;
125
+ }
126
+ /** Plugin snapshot sources folded into token stats but never rendered as rows. */
127
+ const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context']);
128
+ /** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
129
+ const REMINDER_PLUGINS = new Set(['schedule']);
130
+ /** Assemble the effective system prompt from surface nodes: head text plus every later non-empty node. */
131
+ function assembleSystemPrompt(nodes) {
132
+ if (nodes.size === 0)
133
+ return '';
134
+ const ordered = [...nodes.entries()].sort((left, right) => left[0] - right[0]);
135
+ const texts = ordered
136
+ .map(([, text]) => text)
137
+ .filter(text => text !== '');
138
+ return texts.join('\n\n');
139
+ }
140
+ /**
141
+ * Apply one surface event's replace to the live system nodes. Any surface
142
+ * event may shadow system nodes — the kernel's compaction summary lands as a
143
+ * `user/message` replace whose range can cover later system nodes (only node
144
+ * 0 is compaction-protected upstream) — so every surface fold retires covered
145
+ * nodes, not just `system/message` itself.
146
+ * @param nodes - the live system-node map (mutated when the event replaces).
147
+ * @param surfaceOp - the surface operation the event carries, when it is a
148
+ * surface event (log-only events have none and change nothing).
149
+ * @returns the reassembled prompt when nodes were retired, `changed: false`
150
+ * when the event shadows nothing.
151
+ */
152
+ function retireShadowedSystemNodes(nodes, surfaceOp) {
153
+ if (surfaceOp === undefined || surfaceOp === 'append')
154
+ return { prompt: '', changed: false };
155
+ let changed = false;
156
+ for (const seq of nodes.keys()) {
157
+ if (seq >= surfaceOp.startSeq && seq <= surfaceOp.endSeq) {
158
+ nodes.delete(seq);
159
+ changed = true;
160
+ }
161
+ }
162
+ return changed ? { prompt: assembleSystemPrompt(nodes), changed } : { prompt: '', changed: false };
163
+ }
164
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
165
+ function textOf(content) {
166
+ return content.filter(block => block.type === 'text').map(block => block.text).join('');
167
+ }
168
+ /**
169
+ * Snapshot-isolate one anchors block (Maps and their nested Sets): a view
170
+ * already handed to the renderer must never observe a later fold through a
171
+ * shared container. The collections are small and turn-bounded, so cloning
172
+ * per event is cheap next to the entries copy the reducer already makes.
173
+ */
174
+ function cloneViewAnchors(anchors) {
175
+ return {
176
+ stepStart: new Map(anchors.stepStart),
177
+ toolStart: new Map(anchors.toolStart),
178
+ subStart: new Map(anchors.subStart),
179
+ firstChunkAt: new Map(anchors.firstChunkAt),
180
+ compactionTokens: new Map(anchors.compactionTokens),
181
+ lastPruneTokens: anchors.lastPruneTokens,
182
+ turnFiles: new Map([...anchors.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
183
+ turnSteps: new Map(anchors.turnSteps),
184
+ turnTools: new Map([...anchors.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
185
+ systemNodes: new Map(anchors.systemNodes),
186
+ };
187
+ }
188
+ /** Durable image references in their model-visible order. */
189
+ function imagesOf(content) {
190
+ return content.filter((block) => block.type === 'image').map(block => block.attachment);
191
+ }
192
+ /** Durable file references in their model-visible order. */
193
+ function filesOf(content) {
194
+ return content.filter((block) => block.type === 'file').map(block => block.attachment);
195
+ }
196
+ /** Human-readable bounded image labels for transcript, inspector, and export surfaces. */
197
+ export function imageLabels(images) {
198
+ if (images === undefined || images.length === 0)
199
+ return '';
200
+ return images.map((image, index) => {
201
+ const rawName = image.name?.trim() || `image ${index + 1}`;
202
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`;
203
+ const original = image.originalDimensions;
204
+ const dimensions = original === undefined
205
+ ? `${image.width}×${image.height}`
206
+ : `${image.width}×${image.height} · original ${original.width}×${original.height}`;
207
+ return `[image: ${name} · ${dimensions} · ${image.bytes} B]`;
208
+ }).join('\n');
209
+ }
210
+ /** Human-readable bounded file labels for the same surfaces (0.1.5 file blocks). */
211
+ export function fileLabels(files) {
212
+ if (files === undefined || files.length === 0)
213
+ return '';
214
+ return files.map((file, index) => {
215
+ const rawName = file.name?.trim() || `file ${index + 1}`;
216
+ const name = rawName.length <= 80 ? rawName : `${rawName.slice(0, 79)}…`;
217
+ return `[file: ${name} · ${file.bytes} B]`;
218
+ }).join('\n');
219
+ }
220
+ /** Prompt text with its durable image and file labels, without exposing local paths or bytes. */
221
+ export function promptDisplayText(entry) {
222
+ const imageText = imageLabels(entry.images);
223
+ const fileText = fileLabels(entry.files);
224
+ const labels = imageText === '' ? fileText : fileText === '' ? imageText : `${imageText}\n${fileText}`;
225
+ return entry.text === '' ? labels : labels === '' ? entry.text : `${entry.text}\n${labels}`;
226
+ }
227
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
228
+ function reasoningOf(content) {
229
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('');
230
+ }
231
+ /**
232
+ * Rough token estimate for the segmented context bar (pi-nano-context's ~4
233
+ * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
234
+ * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
235
+ * drive bar PROPORTIONS, never billing, so precision is not required.
236
+ * @param text - the text to estimate.
237
+ * @returns an integer token estimate, 0 for empty text.
238
+ */
239
+ function estimateTokens(text) {
240
+ let wide = 0;
241
+ let narrow = 0;
242
+ for (const cluster of splitGraphemes(text)) {
243
+ if (graphemeWidth(cluster) > 1)
244
+ wide += 1;
245
+ else
246
+ narrow += 1;
247
+ }
248
+ return wide + Math.ceil(narrow / 4);
249
+ }
250
+ /** A fresh, empty transcript view. */
251
+ export function createTranscriptView() {
252
+ return {
253
+ entries: [],
254
+ streaming: '',
255
+ streamingReasoning: '',
256
+ todos: [],
257
+ toolCallOrdinal: 0,
258
+ busy: false,
259
+ busySince: 0,
260
+ dscodeCompactingSince: 0,
261
+ model: '',
262
+ plan: false,
263
+ permission: '',
264
+ title: '',
265
+ systemPrompt: '',
266
+ sandbox: '',
267
+ goal: undefined,
268
+ schedules: [],
269
+ pending: { 'next-turn': [], 'next-step': [] },
270
+ claimOrigin: new Map(),
271
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 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: '' },
272
+ anchors: { stepStart: new Map(), toolStart: new Map(), subStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
273
+ };
274
+ }
275
+ /** Full prompt text of a queued message (identical to the durable user row it retires into). */
276
+ function pendingText(content) {
277
+ return textOf(content);
278
+ }
279
+ /**
280
+ * Fold one session event into an updated view (copy-on-write).
281
+ * @param view - the view before the event.
282
+ * @param event - one durable session event from `session/event` or the log.
283
+ * @returns the view after the event; the input view is never mutated.
284
+ */
285
+ export function projectEvent(view, event) {
286
+ // Fold against a private anchors block so the documented contract holds —
287
+ // "the input view is never mutated" — even for the in-place anchor sweeps
288
+ // below; without this, every handed-out view shared live Maps.
289
+ view = { ...view, anchors: cloneViewAnchors(view.anchors) };
290
+ // Any surface event's replace may shadow system nodes (a compaction
291
+ // summary lands as a user/message replace whose range can cover later
292
+ // system nodes); the switch below folds against the shadowed view.
293
+ const shadow = retireShadowedSystemNodes(view.anchors.systemNodes, event.surfaceOp);
294
+ if (shadow.changed) {
295
+ view = {
296
+ ...view,
297
+ systemPrompt: shadow.prompt,
298
+ stats: { ...view.stats, contextSegments: { ...view.stats.contextSegments, system: estimateTokens(shadow.prompt) } },
299
+ };
300
+ }
301
+ switch (event.type) {
302
+ case 'user/message': {
303
+ // A queued row retires when its durable user message lands (the agent
304
+ // claims the inbox and logs the same message identity) — the transient
305
+ // steering/queued preview yields to the real transcript entry.
306
+ const message = event.data;
307
+ let entries = view.entries;
308
+ let pending = view.pending;
309
+ for (const target of ['next-turn', 'next-step']) {
310
+ const index = pending[target].indexOf(message.id);
311
+ if (index < 0)
312
+ continue;
313
+ pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) };
314
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id));
315
+ }
316
+ // How this message reached the agent. The claim that precedes this event
317
+ // is a plain splice (no `canceled` outcome), so it has already emptied
318
+ // the pending row above; `claimOrigin` is what survives to say whether
319
+ // the prompt was queued for this turn or steered into it.
320
+ const origin = view.claimOrigin.get(message.id);
321
+ let claimOrigin = view.claimOrigin;
322
+ if (origin !== undefined) {
323
+ const next = new Map(claimOrigin);
324
+ next.delete(message.id);
325
+ claimOrigin = next;
326
+ }
327
+ const delivery = origin === undefined ? {} : { delivery: origin === 'next-turn' ? 'queued' : 'steered' };
328
+ // Injected context (plugin/model-continuation sources) stays collapsed
329
+ // to a bounded notice row, exactly like collapsed transcript context
330
+ // elsewhere in the product; only direct human prompts render in full.
331
+ const text = textOf(message.content);
332
+ const images = imagesOf(message.content);
333
+ const files = filesOf(message.content);
334
+ if (message.source.kind === 'user' || dscodeVisibleRelay(message)) {
335
+ return {
336
+ ...view,
337
+ pending,
338
+ claimOrigin,
339
+ entries: [...entries, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
340
+ stats: {
341
+ ...view.stats,
342
+ contextSegments: {
343
+ ...view.stats.contextSegments,
344
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
345
+ },
346
+ },
347
+ };
348
+ }
349
+ // Snapshot injections (time/tmux context) still spend model context but
350
+ // render nothing; the schedule reminder is a real prompt and renders in
351
+ // full — the model acts on it, so the transcript must show it.
352
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
353
+ return {
354
+ ...view,
355
+ pending,
356
+ claimOrigin,
357
+ entries,
358
+ stats: {
359
+ ...view.stats,
360
+ contextSegments: {
361
+ ...view.stats.contextSegments,
362
+ system: view.stats.contextSegments.system + estimateTokens(text),
363
+ },
364
+ },
365
+ };
366
+ }
367
+ if (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin)) {
368
+ return {
369
+ ...view,
370
+ pending,
371
+ claimOrigin,
372
+ entries: [...entries, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
373
+ stats: {
374
+ ...view.stats,
375
+ contextSegments: {
376
+ ...view.stats.contextSegments,
377
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
378
+ },
379
+ },
380
+ };
381
+ }
382
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
383
+ ? message.source.summary
384
+ : message.source.kind === 'plugin'
385
+ ? message.source.plugin
386
+ : message.source.kind;
387
+ const summary = boundContextSummary(notice);
388
+ return {
389
+ ...view,
390
+ pending,
391
+ claimOrigin,
392
+ entries: [...entries, { kind: 'user', text: summary, notice: true }],
393
+ stats: {
394
+ ...view.stats,
395
+ contextSegments: {
396
+ ...view.stats.contextSegments,
397
+ system: view.stats.contextSegments.system + estimateTokens(summary),
398
+ },
399
+ },
400
+ };
401
+ }
402
+ case 'agent/inbox/spliced': {
403
+ // The durable inbox mutation (web queue-mirror contract, event-sourced):
404
+ // removals drop the projected rows at their inbox coordinates, inserted
405
+ // messages gain a pending row at their log position.
406
+ const { target, start, removedCount = 0, inserted, outcome } = event.data;
407
+ const ids = view.pending[target];
408
+ const removed = ids.slice(start, start + removedCount);
409
+ // Which list each in-flight message came from, so the durable user
410
+ // message it later becomes can say how it was delivered. Only messages
411
+ // that arrived while a turn was already running count: `followup` is
412
+ // the ordinary submission path too, so an idle submission lands in
413
+ // next-turn exactly like a queued one and must stay unmarked. A claim
414
+ // (no `outcome`) keeps the entry because the user message is still
415
+ // coming; a real cancellation retires it.
416
+ const inFlight = view.busy;
417
+ let claimOrigin = view.claimOrigin;
418
+ let mutableOrigins;
419
+ const remember = (id) => {
420
+ mutableOrigins ??= new Map(claimOrigin);
421
+ mutableOrigins.set(id, target);
422
+ claimOrigin = mutableOrigins;
423
+ };
424
+ if (outcome === 'canceled' && removed.length > 0) {
425
+ const next = new Map(claimOrigin);
426
+ for (const id of removed)
427
+ next.delete(id);
428
+ claimOrigin = next;
429
+ }
430
+ // In-place upstream semantics: the kernel's authoritative fold is
431
+ // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
432
+ // AT the splice position (prepend/replace shapes), never at the tail.
433
+ // A tail append diverged the id order, so later coordinate-based events
434
+ // (next-turn head claims, positioned remove/replace) tombstoned the
435
+ // wrong pending row.
436
+ const nextIds = [
437
+ ...ids.slice(0, start),
438
+ ...inserted.map(message => message.id),
439
+ ...ids.slice(start + removedCount),
440
+ ];
441
+ let entries = view.entries;
442
+ if (removed.length > 0) {
443
+ const removedSet = new Set(removed);
444
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)));
445
+ }
446
+ for (const message of inserted) {
447
+ if (inFlight)
448
+ remember(message.id);
449
+ entries = [...entries, {
450
+ kind: 'pending',
451
+ messageId: message.id,
452
+ target,
453
+ text: pendingText(message.content),
454
+ ...imagesOf(message.content).length === 0 ? {} : { images: imagesOf(message.content) },
455
+ ...filesOf(message.content).length === 0 ? {} : { files: filesOf(message.content) },
456
+ }];
457
+ }
458
+ return { ...view, entries, claimOrigin, pending: { ...view.pending, [target]: nextIds } };
459
+ }
460
+ case 'system/message': {
461
+ // Session-log v3 carries the system prompt as surface nodes (the
462
+ // `request/header.system` field is gone): append adds one node;
463
+ // replace(startSeq, endSeq) retires the covered nodes and this event's
464
+ // node takes their place. The effective prompt is the head text joined
465
+ // with every later non-empty node (kernel in-history assembly), and
466
+ // the context estimate prices that assembly — a tail-clearing
467
+ // replacement never zeroes a surviving head.
468
+ const text = textOf(event.data.message.content);
469
+ const nodes = view.anchors.systemNodes;
470
+ retireShadowedSystemNodes(nodes, event.surfaceOp);
471
+ nodes.set(event.seq, text);
472
+ const systemPrompt = assembleSystemPrompt(nodes);
473
+ return {
474
+ ...view,
475
+ systemPrompt,
476
+ stats: {
477
+ ...view.stats,
478
+ contextSegments: {
479
+ ...view.stats.contextSegments,
480
+ system: estimateTokens(systemPrompt),
481
+ },
482
+ },
483
+ };
484
+ }
485
+ case 'assistant/attempt': {
486
+ // A failed, retried, cancelled, or stream-error attempt that produced no
487
+ // surface message (session-log v2+ folds its chunk stream in here). The
488
+ // step is NOT closed — the kernel's session-stats keeps one step start
489
+ // across in-step retries, so llmMs spans them; keep the anchors so a
490
+ // retrying attempt and its final settlement time the step from one
491
+ // start. An attempt whose first token streamed only live (the frames
492
+ // died before the fold) restores its first-token anchor from the
493
+ // embedded stream exactly like a replayed one.
494
+ const key = `${event.data.turn}:${event.data.step}`;
495
+ let stats = view.stats;
496
+ if (!view.anchors.firstChunkAt.has(key)) {
497
+ const first = assistantStreamFirstTokenTime(event.data.stream ?? []);
498
+ if (first !== undefined) {
499
+ view.anchors.firstChunkAt.set(key, first);
500
+ const started = view.anchors.stepStart.get(key);
501
+ if (started !== undefined) {
502
+ stats = {
503
+ ...stats,
504
+ ttftMs: stats.ttftMs + Math.max(0, first - started),
505
+ ttftSteps: stats.ttftSteps + 1,
506
+ };
507
+ }
508
+ }
509
+ }
510
+ if (view.streaming === '' && view.streamingReasoning === '' && stats === view.stats)
511
+ return view;
512
+ return { ...view, streaming: '', streamingReasoning: '', stats };
513
+ }
514
+ case 'assistant/message': {
515
+ // The assembled message is authoritative; drop the streamed buffers.
516
+ const key = `${event.data.turn}:${event.data.step}`;
517
+ const started = view.anchors.stepStart.get(key);
518
+ view.anchors.stepStart.delete(key);
519
+ // Live streaming anchored the first token through the process-local
520
+ // assistant-stream frames; a replayed settlement has no live frames, so
521
+ // the embedded stream's own first-token time restores the same anchor
522
+ // AND the TTFT figures live frames accumulate on the live path.
523
+ let firstChunk = view.anchors.firstChunkAt.get(key);
524
+ let stats = view.stats;
525
+ if (firstChunk === undefined) {
526
+ firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? []);
527
+ if (firstChunk !== undefined && started !== undefined) {
528
+ stats = {
529
+ ...stats,
530
+ ttftMs: stats.ttftMs + Math.max(0, firstChunk - started),
531
+ ttftSteps: stats.ttftSteps + 1,
532
+ };
533
+ }
534
+ }
535
+ view.anchors.firstChunkAt.delete(key);
536
+ // The assembled message consumes the turn's current step anchor; a
537
+ // later `turn/end` sweep then has nothing left to clean for this step.
538
+ if (view.anchors.turnSteps.get(event.data.turn) === key)
539
+ view.anchors.turnSteps.delete(event.data.turn);
540
+ const usage = event.data.usage;
541
+ const totals = stats.usage;
542
+ const text = textOf(event.data.message.content);
543
+ const reasoning = reasoningOf(event.data.message.content);
544
+ // A tool-only step settles with no text, no reasoning, and (unless the
545
+ // attempt was interrupted) nothing to render: appending an entry would
546
+ // create an invisible zero-line card that pads the inspector's ←→
547
+ // walk and an empty block in /export. The event still updates timing
548
+ // and usage above; only the transcript entry is skipped.
549
+ const renderable = text !== '' || reasoning !== '' || event.data.interrupted === true;
550
+ return {
551
+ ...view,
552
+ streaming: '',
553
+ streamingReasoning: '',
554
+ ...(renderable ? { entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }] } : {}),
555
+ stats: {
556
+ ...stats,
557
+ llmMs: stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
558
+ usage: usage === undefined ? totals : {
559
+ uncachedInputTokens: totals.uncachedInputTokens + usage.inputTokens,
560
+ outputTokens: totals.outputTokens + usage.outputTokens,
561
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
562
+ cacheWriteTokens: totals.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
563
+ },
564
+ lastPromptTokens: usage === undefined ? stats.lastPromptTokens
565
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
566
+ // Decode span and its tokens pair up: an un-timed step (no first
567
+ // chunk landed) contributes neither, so the rate stays honest.
568
+ decodeMs: stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
569
+ decodeTokens: stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
570
+ contextSegments: {
571
+ ...stats.contextSegments,
572
+ thinking: stats.contextSegments.thinking + estimateTokens(reasoning),
573
+ assistant: stats.contextSegments.assistant + estimateTokens(text),
574
+ },
575
+ },
576
+ };
577
+ }
578
+ case 'tool/call': {
579
+ const data = event.data;
580
+ view.anchors.toolStart.set(data.callId, event.time);
581
+ // Remember the call's turn so `turn/end` can sweep a start that never
582
+ // pairs with a result (an interrupted tool otherwise leaks its anchor).
583
+ const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set();
584
+ turnTools.add(data.callId);
585
+ view.anchors.turnTools.set(data.turn, turnTools);
586
+ const ordinal = view.toolCallOrdinal + 1;
587
+ return {
588
+ ...view,
589
+ toolCallOrdinal: ordinal,
590
+ entries: [
591
+ ...view.entries,
592
+ {
593
+ kind: 'tool',
594
+ callId: data.callId,
595
+ ordinal,
596
+ name: data.name,
597
+ arguments: data.arguments,
598
+ preview: toolArgumentsPreview(data.arguments, data.name),
599
+ prompt: toolPromptPreview(data.name, data.arguments),
600
+ state: 'running',
601
+ summary: '',
602
+ detail: undefined,
603
+ subs: [],
604
+ subsDropped: 0,
605
+ }
606
+ ],
607
+ stats: {
608
+ ...view.stats,
609
+ contextSegments: {
610
+ ...view.stats.contextSegments,
611
+ tools: view.stats.contextSegments.tools
612
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
613
+ },
614
+ },
615
+ };
616
+ }
617
+ case 'tool/result': {
618
+ const block = event.data.message.content[0];
619
+ const started = view.anchors.toolStart.get(block.toolCallId);
620
+ view.anchors.toolStart.delete(block.toolCallId);
621
+ // Deregister the call from its turn's registry so `turn/end` does not
622
+ // sweep a start that already paired with a result.
623
+ const turnTools = view.anchors.turnTools.get(event.data.turn);
624
+ if (turnTools !== undefined) {
625
+ turnTools.delete(block.toolCallId);
626
+ if (turnTools.size === 0)
627
+ view.anchors.turnTools.delete(event.data.turn);
628
+ }
629
+ const rawText = textOf(block.content);
630
+ const summary = boundContextSummary(rawText);
631
+ // The verbose expansion self-serves from the persisted presentation
632
+ // metadata (diffs, read windows, web sources) with the bounded raw text
633
+ // as the universal fallback — the capable-UI degradation ladder.
634
+ const detail = toolResultDetail(event.data.meta, rawText);
635
+ // Turn-tail deliverables: a diff-bearing mutation records its paths.
636
+ if (detail?.kind === 'diff') {
637
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set();
638
+ for (const diff of detail.diffs)
639
+ set.add(diff.path);
640
+ view.anchors.turnFiles.set(event.data.turn, set);
641
+ }
642
+ const entries = view.entries.map((entry) => {
643
+ if (entry.kind !== 'tool' || entry.callId !== block.toolCallId)
644
+ return entry;
645
+ return { ...entry, state: block.isError === true ? 'error' : 'done', summary, detail };
646
+ });
647
+ return {
648
+ ...view,
649
+ entries,
650
+ stats: {
651
+ ...view.stats,
652
+ toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
653
+ contextSegments: {
654
+ ...view.stats.contextSegments,
655
+ tools: view.stats.contextSegments.tools + estimateTokens(rawText),
656
+ },
657
+ },
658
+ };
659
+ }
660
+ case 'todo/write':
661
+ return { ...view, todos: event.data.todos };
662
+ case 'turn/start':
663
+ // The web todo projection clears on turn/start: a fresh turn's first
664
+ // write is the authoritative list, and a stale snapshot must not linger
665
+ // through a turn that has not written one yet.
666
+ return {
667
+ ...view,
668
+ busy: true,
669
+ busySince: view.busy ? view.busySince : event.time,
670
+ dscodeCompactingSince: 0,
671
+ todos: [],
672
+ stats: { ...view.stats, turns: view.stats.turns + 1 },
673
+ };
674
+ case 'step/start': {
675
+ // A step supersedes the turn's previous step: if that step never
676
+ // assembled a message (interrupted), its timing anchors are stale the
677
+ // moment the next step opens and are swept here instead of leaking.
678
+ const key = `${event.data.turn}:${event.data.step}`;
679
+ const previous = view.anchors.turnSteps.get(event.data.turn);
680
+ if (previous !== undefined && previous !== key) {
681
+ view.anchors.stepStart.delete(previous);
682
+ view.anchors.firstChunkAt.delete(previous);
683
+ }
684
+ view.anchors.turnSteps.set(event.data.turn, key);
685
+ view.anchors.stepStart.set(key, event.time);
686
+ return {
687
+ ...view,
688
+ streaming: '',
689
+ streamingReasoning: '',
690
+ stats: { ...view.stats, steps: view.stats.steps + 1 },
691
+ };
692
+ }
693
+ case 'turn/end': {
694
+ const reason = event.data.reason;
695
+ const appended = [];
696
+ if (reason.kind === 'error') {
697
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
698
+ ? ' · open /model to add an API key'
699
+ : '';
700
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` });
701
+ }
702
+ else {
703
+ // Non-error outcomes deserve their own durable row (the web renders
704
+ // distinct max-tokens / abort / interruption nodes); `completed` stays
705
+ // silent so an ordinary turn never grows a marker.
706
+ const userCancelled = reason.kind === 'aborted' && reason.reason.kind === 'user';
707
+ const marker = reason.kind === 'aborted'
708
+ ? userCancelled ? 'turn cancelled by the user' : `turn stopped (${reason.reason.kind})`
709
+ : reason.kind === 'max-tokens'
710
+ ? 'turn hit the output-token ceiling (max-tokens)'
711
+ : reason.kind === 'blocked'
712
+ ? 'turn ended blocked'
713
+ : reason.kind === 'interrupted'
714
+ ? 'turn was interrupted by a restart'
715
+ : undefined;
716
+ if (marker !== undefined)
717
+ appended.push({ kind: userCancelled || reason.kind === 'max-tokens' ? 'turn-marker' : 'error', text: marker });
718
+ }
719
+ // Deliverables ride the turn tail (the web's turnTail chips): the
720
+ // turn's mutated files flush as one bounded row, then the set resets.
721
+ const files = view.anchors.turnFiles.get(event.data.turn);
722
+ view.anchors.turnFiles.delete(event.data.turn);
723
+ if (files !== undefined && files.size > 0)
724
+ appended.push({ kind: 'files', paths: [...files].slice(0, 12) });
725
+ // Derivable boundary sweep: the turn is over, so any step/tool anchors
726
+ // it left behind (interruptions that never produced their message or
727
+ // result) can never be resolved and are reclaimed now.
728
+ const stepKey = view.anchors.turnSteps.get(event.data.turn);
729
+ if (stepKey !== undefined) {
730
+ view.anchors.stepStart.delete(stepKey);
731
+ view.anchors.firstChunkAt.delete(stepKey);
732
+ view.anchors.turnSteps.delete(event.data.turn);
733
+ }
734
+ const turnToolSet = view.anchors.turnTools.get(event.data.turn);
735
+ if (turnToolSet !== undefined) {
736
+ for (const callId of turnToolSet)
737
+ view.anchors.toolStart.delete(callId);
738
+ view.anchors.turnTools.delete(event.data.turn);
739
+ }
740
+ // Orphaned retry/command rows can never be resolved after the turn
741
+ // ends: an aborted retry backoff returns upstream without its
742
+ // `llm/retry-started`, and crash repair synthesizes only tool/step/
743
+ // turn closers. Left `running` they pin the settled boundary forever,
744
+ // so the turn end finalizes them exactly like the anchor sweep above.
745
+ let orphans = false;
746
+ const swept = view.entries.map((entry) => {
747
+ if (entry.kind === 'retry' && entry.state === 'running') {
748
+ orphans = true;
749
+ return { ...entry, state: 'done' };
750
+ }
751
+ if (entry.kind === 'command' && entry.state === 'running') {
752
+ orphans = true;
753
+ return { ...entry, state: 'error', summary: 'interrupted before the turn ended' };
754
+ }
755
+ return entry;
756
+ });
757
+ const entries = orphans ? swept : view.entries;
758
+ if (appended.length === 0) {
759
+ return { ...view, busy: false, busySince: 0, dscodeCompactingSince: 0, streaming: '', streamingReasoning: '', entries: dscodeCloseTurn(entries) };
760
+ }
761
+ return {
762
+ ...view,
763
+ busy: false,
764
+ busySince: 0,
765
+ dscodeCompactingSince: 0,
766
+ streaming: '',
767
+ streamingReasoning: '',
768
+ entries: dscodeCloseTurn([...entries, ...appended]),
769
+ };
770
+ }
771
+ case 'llm/retry': {
772
+ const data = event.data;
773
+ return {
774
+ ...view,
775
+ streaming: '',
776
+ streamingReasoning: '',
777
+ entries: [...view.entries, {
778
+ kind: 'retry',
779
+ retryId: data.retryId,
780
+ mode: data.mode,
781
+ attempt: data.retry,
782
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
783
+ code: data.failure.code,
784
+ delayMs: data.delayMs,
785
+ state: 'running',
786
+ }],
787
+ };
788
+ }
789
+ case 'llm/retry-started': {
790
+ const data = event.data;
791
+ const entries = view.entries.map((entry) => {
792
+ if (entry.kind !== 'retry' || entry.retryId !== data.retryId)
793
+ return entry;
794
+ return { ...entry, state: 'done' };
795
+ });
796
+ return { ...view, entries };
797
+ }
798
+ case 'sandbox/mode':
799
+ // Log-only override switch; last write wins for the status badge.
800
+ return { ...view, sandbox: event.data.mode };
801
+ case 'goal/change': {
802
+ const data = event.data;
803
+ const clip = (text) => (text.length > 60 ? `${text.slice(0, 59)}…` : text);
804
+ if (data.operation === 'clear') {
805
+ return {
806
+ ...view,
807
+ goal: undefined,
808
+ entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
809
+ };
810
+ }
811
+ const goal = {
812
+ objective: data.goal.objective,
813
+ phase: data.goal.phase,
814
+ rounds: data.roundsStarted,
815
+ max: data.goal.maxGoalRounds,
816
+ blocked: data.goal.blockedReason?.message ?? '',
817
+ };
818
+ const line = data.operation === 'create'
819
+ ? `◎ goal: ${clip(data.goal.objective)}`
820
+ : data.operation === 'complete'
821
+ ? '◎ goal complete'
822
+ : data.operation === 'pause'
823
+ ? '◎ goal paused'
824
+ : data.operation === 'resume'
825
+ ? '◎ goal resumed'
826
+ : data.operation === 'block'
827
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
828
+ : undefined;
829
+ return {
830
+ ...view,
831
+ goal,
832
+ entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
833
+ };
834
+ }
835
+ case 'schedule/change':
836
+ // Non-conversational catalog state: the /schedule panel renders the
837
+ // active list, the transcript shows only the reminder prompts
838
+ // (handled at user/message above).
839
+ return { ...view, schedules: applyScheduleChange(view.schedules, event.data) };
840
+ case 'session/title':
841
+ // Latest-wins title snapshot, log-only; the status line prefers it.
842
+ return { ...view, title: event.data.title };
843
+ case 'compaction/summary':
844
+ // Remember the shadow price so the matching `compaction/end` row can
845
+ // state what the compaction reclaimed. The map is capped so an aborted
846
+ // compaction (summary without end) cannot leave an unbounded residue.
847
+ if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
848
+ const oldest = view.anchors.compactionTokens.keys().next().value;
849
+ if (oldest !== undefined)
850
+ view.anchors.compactionTokens.delete(oldest);
851
+ }
852
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount);
853
+ return view;
854
+ case 'compaction/start':
855
+ return { ...view, dscodeCompactingSince: event.time };
856
+ case 'compaction/prune':
857
+ // A model-free prune carries no compaction id; its price serves the next
858
+ // `compaction/end` that cannot find a summary price.
859
+ return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } };
860
+ case 'compaction/end': {
861
+ const ok = event.data.error === undefined;
862
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens;
863
+ view.anchors.compactionTokens.delete(event.data.compactionId);
864
+ return {
865
+ ...view,
866
+ dscodeCompactingSince: 0,
867
+ entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
868
+ };
869
+ }
870
+ case 'request/context':
871
+ // Route capacity, logged only when it changes; last one wins.
872
+ return {
873
+ ...view,
874
+ stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
875
+ };
876
+ case 'request/header': {
877
+ // The session's own model record: the latest snapshot's provider/model
878
+ // pair, exactly what a resumed TUI restores as the selection, plus the
879
+ // effective reasoning effort that snapshot carried (the adapter may
880
+ // materialize the model default, which is what the status line shows).
881
+ // The system-prompt estimate lives with `system/message` events since
882
+ // session-log v3 removed the header's `system` field.
883
+ const config = event.data.header.config;
884
+ return {
885
+ ...view,
886
+ model: `${config.provider}/${config.model}`,
887
+ stats: {
888
+ ...view.stats,
889
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
890
+ },
891
+ };
892
+ }
893
+ case 'plan/mode':
894
+ // Whole-value replace; the last one wins (upstream fold semantics).
895
+ return { ...view, plan: event.data.active };
896
+ case 'permission/preset':
897
+ return { ...view, permission: event.data.preset };
898
+ case 'command/run': {
899
+ const data = event.data;
900
+ return {
901
+ ...view,
902
+ entries: [...view.entries, {
903
+ kind: 'command',
904
+ commandId: data.commandId,
905
+ name: data.name,
906
+ args: data.args ?? '',
907
+ state: 'running',
908
+ summary: '',
909
+ }],
910
+ };
911
+ }
912
+ case 'command/done': {
913
+ const data = event.data;
914
+ const entries = view.entries.map((entry) => {
915
+ if (entry.kind !== 'command' || entry.commandId !== data.commandId)
916
+ return entry;
917
+ return {
918
+ ...entry,
919
+ state: data.kind === 'success' ? 'done' : 'error',
920
+ summary: boundContextSummary(data.text ?? ''),
921
+ };
922
+ });
923
+ return { ...view, entries };
924
+ }
925
+ default: {
926
+ // `tool/ptc-dispatch*` live in @deepseek-ai/dsh-tools' map merge,
927
+ // which this bundle does not depend on; the string guards keep the
928
+ // fold decoupled while following the upstream contract exactly (pair
929
+ // by subCallId; every start settles; settle speaks tool/result's own
930
+ // vocabulary). Log-only upstream: deriveMessages ignores them, so the
931
+ // sub rows are pure display state on the parent run_code card.
932
+ const type = event.type;
933
+ if (type === 'tool/ptc-dispatch-start') {
934
+ const data = event.data;
935
+ view.anchors.subStart.set(data.subCallId, event.time);
936
+ const entries = view.entries.map(entry => entry.kind !== 'tool' || entry.callId !== data.rootCallId
937
+ ? entry
938
+ : pushSubDispatch(entry, {
939
+ subCallId: data.subCallId,
940
+ name: data.name,
941
+ preview: toolArgumentsPreview(subDispatchArguments(data.arguments), data.name),
942
+ state: 'running',
943
+ summary: '',
944
+ durationMs: 0,
945
+ }));
946
+ return { ...view, entries };
947
+ }
948
+ if (type === 'tool/ptc-dispatch') {
949
+ const data = event.data;
950
+ const started = view.anchors.subStart.get(data.subCallId);
951
+ view.anchors.subStart.delete(data.subCallId);
952
+ const summary = boundContextSummary(textOf(data.content));
953
+ const durationMs = started === undefined ? 0 : Math.max(0, event.time - started);
954
+ const entries = view.entries.map(entry => entry.kind !== 'tool' || entry.callId !== data.rootCallId
955
+ ? entry
956
+ : settleSubDispatch(entry, data, summary, durationMs));
957
+ return { ...view, entries };
958
+ }
959
+ // The durable workflow record (`tool-workflow/*`, appended by the
960
+ // workflow/ralph tools): one bounded card per run, members paired by
961
+ // their sequence number, the run settling on run-end.
962
+ if (type === 'tool-workflow/run-start') {
963
+ const data = event.data;
964
+ return {
965
+ ...view,
966
+ entries: [...view.entries, {
967
+ kind: 'workflow',
968
+ runId: data.runId,
969
+ name: data.name,
970
+ members: [],
971
+ membersDropped: 0,
972
+ state: 'running',
973
+ }],
974
+ };
975
+ }
976
+ if (type === 'tool-workflow/agent-start') {
977
+ const data = event.data;
978
+ const entries = view.entries.map(entry => entry.kind !== 'workflow' || entry.runId !== data.runId
979
+ ? entry
980
+ : pushWorkflowMember(entry, {
981
+ seq: data.seq,
982
+ label: data.label,
983
+ phase: data.phase ?? '',
984
+ childId: data.childId,
985
+ outcome: 'running',
986
+ }));
987
+ return { ...view, entries };
988
+ }
989
+ if (type === 'tool-workflow/agent-end') {
990
+ const data = event.data;
991
+ const entries = view.entries.map(entry => entry.kind !== 'workflow' || entry.runId !== data.runId
992
+ ? entry
993
+ : settleWorkflowMember(entry, data));
994
+ return { ...view, entries };
995
+ }
996
+ if (type === 'tool-workflow/run-end') {
997
+ const data = event.data;
998
+ const entries = view.entries.map(entry => entry.kind !== 'workflow' || entry.runId !== data.runId
999
+ ? entry
1000
+ : { ...entry, state: data.stopReason });
1001
+ return { ...view, entries };
1002
+ }
1003
+ return view;
1004
+ }
1005
+ }
1006
+ }
1007
+ /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
1008
+ export function createReplayAccumulator() {
1009
+ return {
1010
+ entries: [],
1011
+ toolIndex: new Map(),
1012
+ commandIndex: new Map(),
1013
+ retryIndex: new Map(),
1014
+ pendingIndex: new Map(),
1015
+ workflowIndex: new Map(),
1016
+ removedCount: 0,
1017
+ pendingTurn: [],
1018
+ pendingStep: [],
1019
+ claimOrigin: new Map(),
1020
+ streaming: '',
1021
+ streamingReasoning: '',
1022
+ todos: [],
1023
+ toolCallOrdinal: 0,
1024
+ busy: false,
1025
+ busySince: 0,
1026
+ dscodeCompactingSince: 0,
1027
+ model: '',
1028
+ plan: false,
1029
+ permission: '',
1030
+ title: '',
1031
+ systemPrompt: '',
1032
+ sandbox: '',
1033
+ goal: undefined,
1034
+ schedules: [],
1035
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 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: '' },
1036
+ stepStart: new Map(),
1037
+ toolStart: new Map(),
1038
+ subStart: new Map(),
1039
+ firstChunkAt: new Map(),
1040
+ compactionTokens: new Map(),
1041
+ lastPruneTokens: 0,
1042
+ turnFiles: new Map(),
1043
+ turnSteps: new Map(),
1044
+ turnTools: new Map(),
1045
+ systemNodes: new Map(),
1046
+ ops: 0,
1047
+ };
1048
+ }
1049
+ /** Append one entry (O(1)) and account the push. */
1050
+ function appendReplayEntry(acc, entry) {
1051
+ acc.entries.push(entry);
1052
+ acc.ops += 1;
1053
+ }
1054
+ /**
1055
+ * Get (or create) the index list an id owns. Lists are never removed: every
1056
+ * appended row registers its index, so a lookup miss later proves no matching
1057
+ * row exists and the caller can no-op in O(1).
1058
+ */
1059
+ function indexList(map, id) {
1060
+ let list = map.get(id);
1061
+ if (list === undefined) {
1062
+ list = [];
1063
+ map.set(id, list);
1064
+ }
1065
+ return list;
1066
+ }
1067
+ /**
1068
+ * Finalize replay rows the ended turn left `running`, mirroring the reducer's
1069
+ * turn-end orphan sweep: an orphaned retry settles `done`, an orphaned command
1070
+ * settles `error` with an interruption note. Only the id-indexed rows are
1071
+ * visited, so the sweep stays O(retries+commands of the log), never a scan.
1072
+ */
1073
+ function finalizeReplayOrphans(acc) {
1074
+ for (const list of acc.retryIndex.values()) {
1075
+ for (const index of list) {
1076
+ const entry = acc.entries[index];
1077
+ if (entry !== undefined && entry.kind === 'retry' && entry.state === 'running') {
1078
+ acc.entries[index] = { ...entry, state: 'done' };
1079
+ }
1080
+ }
1081
+ }
1082
+ for (const list of acc.commandIndex.values()) {
1083
+ for (const index of list) {
1084
+ const entry = acc.entries[index];
1085
+ if (entry !== undefined && entry.kind === 'command' && entry.state === 'running') {
1086
+ acc.entries[index] = { ...entry, state: 'error', summary: 'interrupted before the turn ended' };
1087
+ }
1088
+ }
1089
+ }
1090
+ }
1091
+ /**
1092
+ * Apply an id-keyed update to every row that registered the id, mirroring the
1093
+ * copy-on-write reducer's full-array map semantics (all matching rows update,
1094
+ * in order). Each registered index is O(1), so a duplicate id costs
1095
+ * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
1096
+ * registered indices are valid by construction, because tool/command/retry
1097
+ * rows are never removed and tombstones never shift indices.
1098
+ */
1099
+ function updateReplayById(acc, map, id, isMatch, update) {
1100
+ const list = map.get(id);
1101
+ if (list === undefined)
1102
+ return; // miss provably means no matching row
1103
+ for (const index of list) {
1104
+ const entry = acc.entries[index];
1105
+ if (entry === undefined || !isMatch(entry))
1106
+ continue;
1107
+ acc.entries[index] = update(entry);
1108
+ acc.ops += 1;
1109
+ }
1110
+ }
1111
+ /** Tombstone a retired pending row, keeping every other index stable. */
1112
+ function retireReplayEntry(acc, index) {
1113
+ if (acc.entries[index] !== undefined) {
1114
+ acc.entries[index] = undefined;
1115
+ acc.removedCount += 1;
1116
+ acc.ops += 1;
1117
+ }
1118
+ }
1119
+ /**
1120
+ * Fold one session event into a replay accumulator. This mirrors
1121
+ * {@link projectEvent} case for case — same stats arithmetic, same anchor
1122
+ * set/delete behavior, same entry shapes — so the finished view is identical
1123
+ * to a sequential fold; only the `entries` container operations are mutable.
1124
+ *
1125
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1126
+ * @returns whether the event changed the accumulated state — the live store
1127
+ * stays silent and keeps its snapshot identity for ignored events, exactly
1128
+ * like the copy-on-write reducer returning its input view unchanged.
1129
+ */
1130
+ export function replayProjectEvent(acc, event) {
1131
+ // Mirror of the reducer's entry shadow: any surface replace retires the
1132
+ // system nodes it covers before the per-event fold runs.
1133
+ const shadow = retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp);
1134
+ if (shadow.changed) {
1135
+ acc.systemPrompt = shadow.prompt;
1136
+ acc.stats = { ...acc.stats, contextSegments: { ...acc.stats.contextSegments, system: estimateTokens(shadow.prompt) } };
1137
+ }
1138
+ switch (event.type) {
1139
+ case 'user/message': {
1140
+ const message = event.data;
1141
+ for (const target of ['next-turn', 'next-step']) {
1142
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep;
1143
+ const index = ids.indexOf(message.id);
1144
+ acc.ops += index < 0 ? ids.length : index + 1;
1145
+ if (index < 0)
1146
+ continue;
1147
+ ids.splice(index, 1);
1148
+ acc.ops += 1;
1149
+ // Retire every pending row carrying this message id (duplicate ids
1150
+ // included), exactly like the reducer's full-array filter.
1151
+ const list = acc.pendingIndex.get(message.id);
1152
+ if (list !== undefined) {
1153
+ for (const entryIndex of list)
1154
+ retireReplayEntry(acc, entryIndex);
1155
+ acc.ops += 1;
1156
+ }
1157
+ }
1158
+ const text = textOf(message.content);
1159
+ const images = imagesOf(message.content);
1160
+ const files = filesOf(message.content);
1161
+ // Same delivery-origin rule as the reducer: the claim splice that
1162
+ // precedes this event never carried the origin, so `claimOrigin` is the
1163
+ // only surviving record of how the prompt was submitted.
1164
+ const origin = acc.claimOrigin.get(message.id);
1165
+ const delivery = origin === undefined ? {} : { delivery: origin === 'next-turn' ? 'queued' : 'steered' };
1166
+ acc.claimOrigin.delete(message.id);
1167
+ if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
1168
+ appendReplayEntry(acc, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) });
1169
+ acc.stats = {
1170
+ ...acc.stats,
1171
+ contextSegments: {
1172
+ ...acc.stats.contextSegments,
1173
+ prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
1174
+ },
1175
+ };
1176
+ return true;
1177
+ }
1178
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
1179
+ acc.stats = {
1180
+ ...acc.stats,
1181
+ contextSegments: {
1182
+ ...acc.stats.contextSegments,
1183
+ system: acc.stats.contextSegments.system + estimateTokens(text),
1184
+ },
1185
+ };
1186
+ return true;
1187
+ }
1188
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1189
+ ? message.source.summary
1190
+ : message.source.kind === 'plugin'
1191
+ ? message.source.plugin
1192
+ : message.source.kind;
1193
+ const summary = boundContextSummary(notice);
1194
+ appendReplayEntry(acc, { kind: 'user', text: summary, notice: true });
1195
+ acc.stats = {
1196
+ ...acc.stats,
1197
+ contextSegments: {
1198
+ ...acc.stats.contextSegments,
1199
+ system: acc.stats.contextSegments.system + estimateTokens(summary),
1200
+ },
1201
+ };
1202
+ return true;
1203
+ }
1204
+ case 'agent/inbox/spliced': {
1205
+ const { target, start, removedCount = 0, inserted, outcome } = event.data;
1206
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep;
1207
+ const removed = ids.slice(start, start + removedCount);
1208
+ acc.ops += removed.length;
1209
+ ids.splice(start, removedCount);
1210
+ acc.ops += removed.length;
1211
+ // A claim keeps the origin for the user message still to come; a real
1212
+ // cancellation retires it with the row.
1213
+ if (outcome === 'canceled')
1214
+ for (const id of removed)
1215
+ acc.claimOrigin.delete(id);
1216
+ for (const id of removed) {
1217
+ const list = acc.pendingIndex.get(id);
1218
+ if (list === undefined)
1219
+ continue;
1220
+ for (const entryIndex of list) {
1221
+ const entry = acc.entries[entryIndex];
1222
+ if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1223
+ retireReplayEntry(acc, entryIndex);
1224
+ }
1225
+ }
1226
+ }
1227
+ // Mirror the reducer's in-place order: inserted ids join at the splice
1228
+ // position (upstream `splice(start, removedCount, ...inserted)`), never
1229
+ // at the tail — the id list must stay coordinate-compatible with every
1230
+ // later inbox event.
1231
+ ids.splice(start, 0, ...inserted.map(message => message.id));
1232
+ for (const message of inserted) {
1233
+ // Same rule as the reducer: only a submission that arrived while a
1234
+ // turn was running is "queued"/"steered" rather than ordinary.
1235
+ if (acc.busy)
1236
+ acc.claimOrigin.set(message.id, target);
1237
+ const images = imagesOf(message.content);
1238
+ const files = filesOf(message.content);
1239
+ appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) });
1240
+ indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1);
1241
+ acc.ops += 1;
1242
+ }
1243
+ return true;
1244
+ }
1245
+ case 'system/message': {
1246
+ // Mirrors the reducer's per-node fold: append adds, replace retires the
1247
+ // covered seq range, and the estimate prices the assembled prompt.
1248
+ const text = textOf(event.data.message.content);
1249
+ retireShadowedSystemNodes(acc.systemNodes, event.surfaceOp);
1250
+ acc.systemNodes.set(event.seq, text);
1251
+ acc.systemPrompt = assembleSystemPrompt(acc.systemNodes);
1252
+ acc.stats = {
1253
+ ...acc.stats,
1254
+ contextSegments: {
1255
+ ...acc.stats.contextSegments,
1256
+ system: estimateTokens(acc.systemPrompt),
1257
+ },
1258
+ };
1259
+ return true;
1260
+ }
1261
+ case 'assistant/attempt': {
1262
+ // Mirrors the reducer: the step stays open across in-step retries; a
1263
+ // missing first-token anchor is restored (and accrued) from the
1264
+ // attempt's embedded stream, and only the live tails are dropped.
1265
+ const key = `${event.data.turn}:${event.data.step}`;
1266
+ let changed = false;
1267
+ if (!acc.firstChunkAt.has(key)) {
1268
+ const first = assistantStreamFirstTokenTime(event.data.stream ?? []);
1269
+ if (first !== undefined) {
1270
+ acc.firstChunkAt.set(key, first);
1271
+ const started = acc.stepStart.get(key);
1272
+ if (started !== undefined) {
1273
+ acc.stats = {
1274
+ ...acc.stats,
1275
+ ttftMs: acc.stats.ttftMs + Math.max(0, first - started),
1276
+ ttftSteps: acc.stats.ttftSteps + 1,
1277
+ };
1278
+ }
1279
+ changed = true;
1280
+ }
1281
+ }
1282
+ const streamed = acc.streaming !== '' || acc.streamingReasoning !== '';
1283
+ acc.streaming = '';
1284
+ acc.streamingReasoning = '';
1285
+ return changed || streamed;
1286
+ }
1287
+ case 'assistant/message': {
1288
+ const key = `${event.data.turn}:${event.data.step}`;
1289
+ const started = acc.stepStart.get(key);
1290
+ acc.stepStart.delete(key);
1291
+ let firstChunk = acc.firstChunkAt.get(key);
1292
+ if (firstChunk === undefined) {
1293
+ // A replayed settlement has no live frames; the embedded stream's
1294
+ // first-token time restores both the anchor and the TTFT figures the
1295
+ // live path accumulates in applyAssistantStreamChunk.
1296
+ firstChunk = assistantStreamFirstTokenTime(event.data.stream ?? []);
1297
+ if (firstChunk !== undefined && started !== undefined) {
1298
+ acc.stats = {
1299
+ ...acc.stats,
1300
+ ttftMs: acc.stats.ttftMs + Math.max(0, firstChunk - started),
1301
+ ttftSteps: acc.stats.ttftSteps + 1,
1302
+ };
1303
+ }
1304
+ }
1305
+ acc.firstChunkAt.delete(key);
1306
+ if (acc.turnSteps.get(event.data.turn) === key)
1307
+ acc.turnSteps.delete(event.data.turn);
1308
+ const usage = event.data.usage;
1309
+ const totals = acc.stats.usage;
1310
+ const text = textOf(event.data.message.content);
1311
+ const reasoning = reasoningOf(event.data.message.content);
1312
+ acc.streaming = '';
1313
+ acc.streamingReasoning = '';
1314
+ // Same zero-line guard as the live fold: tool-only settlements carry
1315
+ // timing/usage but no renderable transcript entry.
1316
+ if (text !== '' || reasoning !== '' || event.data.interrupted === true) {
1317
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined });
1318
+ }
1319
+ acc.stats = {
1320
+ ...acc.stats,
1321
+ llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1322
+ usage: usage === undefined ? totals : {
1323
+ uncachedInputTokens: totals.uncachedInputTokens + usage.inputTokens,
1324
+ outputTokens: totals.outputTokens + usage.outputTokens,
1325
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1326
+ cacheWriteTokens: totals.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
1327
+ },
1328
+ lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1329
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1330
+ decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1331
+ decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1332
+ contextSegments: {
1333
+ ...acc.stats.contextSegments,
1334
+ thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1335
+ assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1336
+ },
1337
+ };
1338
+ return true;
1339
+ }
1340
+ case 'tool/call': {
1341
+ const data = event.data;
1342
+ acc.toolStart.set(data.callId, event.time);
1343
+ const turnTools = acc.turnTools.get(data.turn) ?? new Set();
1344
+ turnTools.add(data.callId);
1345
+ acc.turnTools.set(data.turn, turnTools);
1346
+ acc.toolCallOrdinal += 1;
1347
+ appendReplayEntry(acc, {
1348
+ kind: 'tool',
1349
+ callId: data.callId,
1350
+ ordinal: acc.toolCallOrdinal,
1351
+ name: data.name,
1352
+ arguments: data.arguments,
1353
+ preview: toolArgumentsPreview(data.arguments, data.name),
1354
+ prompt: toolPromptPreview(data.name, data.arguments),
1355
+ state: 'running',
1356
+ summary: '',
1357
+ detail: undefined,
1358
+ subs: [],
1359
+ subsDropped: 0,
1360
+ });
1361
+ indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1);
1362
+ acc.stats = {
1363
+ ...acc.stats,
1364
+ contextSegments: {
1365
+ ...acc.stats.contextSegments,
1366
+ tools: acc.stats.contextSegments.tools
1367
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1368
+ },
1369
+ };
1370
+ return true;
1371
+ }
1372
+ case 'tool/result': {
1373
+ const block = event.data.message.content[0];
1374
+ const started = acc.toolStart.get(block.toolCallId);
1375
+ acc.toolStart.delete(block.toolCallId);
1376
+ const turnTools = acc.turnTools.get(event.data.turn);
1377
+ if (turnTools !== undefined) {
1378
+ turnTools.delete(block.toolCallId);
1379
+ if (turnTools.size === 0)
1380
+ acc.turnTools.delete(event.data.turn);
1381
+ }
1382
+ const rawText = textOf(block.content);
1383
+ const summary = boundContextSummary(rawText);
1384
+ const detail = toolResultDetail(event.data.meta, rawText);
1385
+ if (detail?.kind === 'diff') {
1386
+ const set = acc.turnFiles.get(event.data.turn) ?? new Set();
1387
+ for (const diff of detail.diffs)
1388
+ set.add(diff.path);
1389
+ acc.turnFiles.set(event.data.turn, set);
1390
+ }
1391
+ const update = (entry) => ({
1392
+ ...entry,
1393
+ state: block.isError === true ? 'error' : 'done',
1394
+ summary,
1395
+ detail,
1396
+ });
1397
+ // Every matching row updates (duplicate callIds included); an id with no
1398
+ // registered index is a provable no-op — no full-array fallback scan.
1399
+ updateReplayById(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update);
1400
+ acc.stats = {
1401
+ ...acc.stats,
1402
+ toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1403
+ contextSegments: {
1404
+ ...acc.stats.contextSegments,
1405
+ tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1406
+ },
1407
+ };
1408
+ return true;
1409
+ }
1410
+ case 'todo/write':
1411
+ acc.todos = event.data.todos;
1412
+ return true;
1413
+ case 'turn/start': {
1414
+ const wasBusy = acc.busy;
1415
+ acc.busy = true;
1416
+ acc.busySince = wasBusy ? acc.busySince : event.time;
1417
+ acc.dscodeCompactingSince = 0;
1418
+ acc.todos = [];
1419
+ acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 };
1420
+ return true;
1421
+ }
1422
+ case 'step/start': {
1423
+ const key = `${event.data.turn}:${event.data.step}`;
1424
+ const previous = acc.turnSteps.get(event.data.turn);
1425
+ if (previous !== undefined && previous !== key) {
1426
+ acc.stepStart.delete(previous);
1427
+ acc.firstChunkAt.delete(previous);
1428
+ }
1429
+ acc.turnSteps.set(event.data.turn, key);
1430
+ acc.stepStart.set(key, event.time);
1431
+ acc.streaming = '';
1432
+ acc.streamingReasoning = '';
1433
+ acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 };
1434
+ return true;
1435
+ }
1436
+ case 'turn/end': {
1437
+ const reason = event.data.reason;
1438
+ const appended = [];
1439
+ acc.streamingReasoning = '';
1440
+ acc.streaming = '';
1441
+ if (reason.kind === 'error') {
1442
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1443
+ ? ' · open /model to add an API key'
1444
+ : '';
1445
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` });
1446
+ }
1447
+ else {
1448
+ const userCancelled = reason.kind === 'aborted' && reason.reason.kind === 'user';
1449
+ const marker = reason.kind === 'aborted'
1450
+ ? userCancelled ? 'turn cancelled by the user' : `turn stopped (${reason.reason.kind})`
1451
+ : reason.kind === 'max-tokens'
1452
+ ? 'turn hit the output-token ceiling (max-tokens)'
1453
+ : reason.kind === 'blocked'
1454
+ ? 'turn ended blocked'
1455
+ : reason.kind === 'interrupted'
1456
+ ? 'turn was interrupted by a restart'
1457
+ : undefined;
1458
+ if (marker !== undefined)
1459
+ appended.push({ kind: userCancelled || reason.kind === 'max-tokens' ? 'turn-marker' : 'error', text: marker });
1460
+ }
1461
+ const files = acc.turnFiles.get(event.data.turn);
1462
+ acc.turnFiles.delete(event.data.turn);
1463
+ if (files !== undefined && files.size > 0)
1464
+ appended.push({ kind: 'files', paths: [...files].slice(0, 12) });
1465
+ const stepKey = acc.turnSteps.get(event.data.turn);
1466
+ if (stepKey !== undefined) {
1467
+ acc.stepStart.delete(stepKey);
1468
+ acc.firstChunkAt.delete(stepKey);
1469
+ acc.turnSteps.delete(event.data.turn);
1470
+ }
1471
+ const turnToolSet = acc.turnTools.get(event.data.turn);
1472
+ if (turnToolSet !== undefined) {
1473
+ for (const callId of turnToolSet)
1474
+ acc.toolStart.delete(callId);
1475
+ acc.turnTools.delete(event.data.turn);
1476
+ }
1477
+ finalizeReplayOrphans(acc);
1478
+ acc.busy = false;
1479
+ acc.busySince = 0;
1480
+ acc.dscodeCompactingSince = 0;
1481
+ for (const entry of appended)
1482
+ appendReplayEntry(acc, entry);
1483
+ return true;
1484
+ }
1485
+ case 'llm/retry': {
1486
+ const data = event.data;
1487
+ acc.streaming = '';
1488
+ acc.streamingReasoning = '';
1489
+ appendReplayEntry(acc, {
1490
+ kind: 'retry',
1491
+ retryId: data.retryId,
1492
+ mode: data.mode,
1493
+ attempt: data.retry,
1494
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
1495
+ code: data.failure.code,
1496
+ delayMs: data.delayMs,
1497
+ state: 'running',
1498
+ });
1499
+ indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1);
1500
+ return true;
1501
+ }
1502
+ case 'llm/retry-started': {
1503
+ const data = event.data;
1504
+ updateReplayById(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' }));
1505
+ return true;
1506
+ }
1507
+ case 'sandbox/mode':
1508
+ acc.sandbox = event.data.mode;
1509
+ return true;
1510
+ case 'goal/change': {
1511
+ const data = event.data;
1512
+ const clip = (text) => (text.length > 60 ? `${text.slice(0, 59)}…` : text);
1513
+ if (data.operation === 'clear') {
1514
+ acc.goal = undefined;
1515
+ appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' });
1516
+ return true;
1517
+ }
1518
+ const goal = {
1519
+ objective: data.goal.objective,
1520
+ phase: data.goal.phase,
1521
+ rounds: data.roundsStarted,
1522
+ max: data.goal.maxGoalRounds,
1523
+ blocked: data.goal.blockedReason?.message ?? '',
1524
+ };
1525
+ const line = data.operation === 'create'
1526
+ ? `◎ goal: ${clip(data.goal.objective)}`
1527
+ : data.operation === 'complete'
1528
+ ? '◎ goal complete'
1529
+ : data.operation === 'pause'
1530
+ ? '◎ goal paused'
1531
+ : data.operation === 'resume'
1532
+ ? '◎ goal resumed'
1533
+ : data.operation === 'block'
1534
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
1535
+ : undefined;
1536
+ acc.goal = goal;
1537
+ if (line !== undefined)
1538
+ appendReplayEntry(acc, { kind: 'turn-marker', text: line });
1539
+ return true;
1540
+ }
1541
+ case 'schedule/change':
1542
+ acc.schedules = applyScheduleChange(acc.schedules, event.data);
1543
+ acc.ops += 1;
1544
+ return true;
1545
+ case 'session/title':
1546
+ acc.title = event.data.title;
1547
+ return true;
1548
+ case 'compaction/summary':
1549
+ if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1550
+ const oldest = acc.compactionTokens.keys().next().value;
1551
+ if (oldest !== undefined)
1552
+ acc.compactionTokens.delete(oldest);
1553
+ }
1554
+ acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount);
1555
+ return true;
1556
+ case 'compaction/start':
1557
+ acc.dscodeCompactingSince = event.time;
1558
+ return true;
1559
+ case 'compaction/prune':
1560
+ acc.lastPruneTokens = event.data.shadowedTokenCount;
1561
+ return true;
1562
+ case 'compaction/end': {
1563
+ const ok = event.data.error === undefined;
1564
+ const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens;
1565
+ acc.compactionTokens.delete(event.data.compactionId);
1566
+ acc.dscodeCompactingSince = 0;
1567
+ appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' });
1568
+ return true;
1569
+ }
1570
+ case 'request/context':
1571
+ acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow };
1572
+ return true;
1573
+ case 'request/header': {
1574
+ const config = event.data.header.config;
1575
+ acc.model = `${config.provider}/${config.model}`;
1576
+ acc.stats = {
1577
+ ...acc.stats,
1578
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1579
+ };
1580
+ return true;
1581
+ }
1582
+ case 'plan/mode':
1583
+ acc.plan = event.data.active;
1584
+ return true;
1585
+ case 'permission/preset':
1586
+ acc.permission = event.data.preset;
1587
+ return true;
1588
+ case 'command/run': {
1589
+ const data = event.data;
1590
+ appendReplayEntry(acc, {
1591
+ kind: 'command',
1592
+ commandId: data.commandId,
1593
+ name: data.name,
1594
+ args: data.args ?? '',
1595
+ state: 'running',
1596
+ summary: '',
1597
+ });
1598
+ indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1);
1599
+ return true;
1600
+ }
1601
+ case 'command/done': {
1602
+ const data = event.data;
1603
+ const update = (candidate) => ({
1604
+ ...candidate,
1605
+ state: data.kind === 'success' ? 'done' : 'error',
1606
+ summary: boundContextSummary(data.text ?? ''),
1607
+ });
1608
+ updateReplayById(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update);
1609
+ return true;
1610
+ }
1611
+ default: {
1612
+ // PTC sub-dispatch pair, string-guarded like the live fold above; the
1613
+ // parent run_code row is addressed through the same call-id index the
1614
+ // tool/result case uses.
1615
+ const type = event.type;
1616
+ if (type === 'tool/ptc-dispatch-start') {
1617
+ const data = event.data;
1618
+ acc.subStart.set(data.subCallId, event.time);
1619
+ updateReplayById(acc, acc.toolIndex, data.rootCallId, entry => entry.callId === data.rootCallId, entry => pushSubDispatch(entry, {
1620
+ subCallId: data.subCallId,
1621
+ name: data.name,
1622
+ preview: toolArgumentsPreview(subDispatchArguments(data.arguments), data.name),
1623
+ state: 'running',
1624
+ summary: '',
1625
+ durationMs: 0,
1626
+ }));
1627
+ return true;
1628
+ }
1629
+ if (type === 'tool/ptc-dispatch') {
1630
+ const data = event.data;
1631
+ const started = acc.subStart.get(data.subCallId);
1632
+ acc.subStart.delete(data.subCallId);
1633
+ const summary = boundContextSummary(textOf(data.content));
1634
+ const durationMs = started === undefined ? 0 : Math.max(0, event.time - started);
1635
+ updateReplayById(acc, acc.toolIndex, data.rootCallId, entry => entry.callId === data.rootCallId, entry => settleSubDispatch(entry, data, summary, durationMs));
1636
+ return true;
1637
+ }
1638
+ // Durable workflow record, string-guarded like the live fold above.
1639
+ if (type === 'tool-workflow/run-start') {
1640
+ const data = event.data;
1641
+ appendReplayEntry(acc, {
1642
+ kind: 'workflow',
1643
+ runId: data.runId,
1644
+ name: data.name,
1645
+ members: [],
1646
+ membersDropped: 0,
1647
+ state: 'running',
1648
+ });
1649
+ indexList(acc.workflowIndex, data.runId).push(acc.entries.length - 1);
1650
+ return true;
1651
+ }
1652
+ if (type === 'tool-workflow/agent-start') {
1653
+ const data = event.data;
1654
+ updateReplayById(acc, acc.workflowIndex, data.runId, entry => entry.runId === data.runId, entry => pushWorkflowMember(entry, {
1655
+ seq: data.seq,
1656
+ label: data.label,
1657
+ phase: data.phase ?? '',
1658
+ childId: data.childId,
1659
+ outcome: 'running',
1660
+ }));
1661
+ return true;
1662
+ }
1663
+ if (type === 'tool-workflow/agent-end') {
1664
+ const data = event.data;
1665
+ updateReplayById(acc, acc.workflowIndex, data.runId, entry => entry.runId === data.runId, entry => settleWorkflowMember(entry, data));
1666
+ return true;
1667
+ }
1668
+ if (type === 'tool-workflow/run-end') {
1669
+ const data = event.data;
1670
+ updateReplayById(acc, acc.workflowIndex, data.runId, entry => entry.runId === data.runId, entry => ({ ...entry, state: data.stopReason }));
1671
+ return true;
1672
+ }
1673
+ return false;
1674
+ }
1675
+ }
1676
+ }
1677
+ /**
1678
+ * Materialize the accumulated fold as a `TranscriptView`, compacting any
1679
+ * retired tombstones. The anchors maps are handed through as-is (their
1680
+ * content is identical to a sequential fold's).
1681
+ *
1682
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1683
+ */
1684
+ export function finishReplay(acc) {
1685
+ return materializeReplayView(acc, false);
1686
+ }
1687
+ /**
1688
+ * Materialize the accumulated fold as a fresh immutable snapshot for the
1689
+ * live store. Unlike {@link finishReplay} — the one-shot replay entry, which
1690
+ * hands the accumulator's own arrays through because the accumulator is
1691
+ * discarded — every array a renderer can hold is copied here, so later
1692
+ * folds never mutate a snapshot already handed out. Same fields, same
1693
+ * tombstone compaction.
1694
+ *
1695
+ * @internal Live-store path; `projectEvents` is the public entry.
1696
+ */
1697
+ export function snapshotReplayView(acc) {
1698
+ return materializeReplayView(acc, true);
1699
+ }
1700
+ /** Field-for-field materialization; `copy` selects snapshot array isolation. */
1701
+ function materializeReplayView(acc, copy) {
1702
+ const entries = acc.removedCount === 0
1703
+ ? (copy ? [...acc.entries] : acc.entries)
1704
+ : acc.entries.filter((entry) => entry !== undefined);
1705
+ if (acc.removedCount > 0)
1706
+ acc.ops += acc.entries.length;
1707
+ return {
1708
+ entries,
1709
+ streaming: acc.streaming,
1710
+ streamingReasoning: acc.streamingReasoning,
1711
+ todos: acc.todos,
1712
+ toolCallOrdinal: acc.toolCallOrdinal,
1713
+ busy: acc.busy,
1714
+ busySince: acc.busySince,
1715
+ dscodeCompactingSince: acc.dscodeCompactingSince,
1716
+ model: acc.model,
1717
+ plan: acc.plan,
1718
+ permission: acc.permission,
1719
+ title: acc.title,
1720
+ systemPrompt: acc.systemPrompt,
1721
+ sandbox: acc.sandbox,
1722
+ goal: acc.goal,
1723
+ schedules: acc.schedules,
1724
+ pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1725
+ claimOrigin: new Map(acc.claimOrigin),
1726
+ stats: acc.stats,
1727
+ // Handed-out views get their own anchors snapshot: the accumulator keeps
1728
+ // folding its live containers, and no consumer may observe that.
1729
+ anchors: {
1730
+ stepStart: new Map(acc.stepStart),
1731
+ toolStart: new Map(acc.toolStart),
1732
+ subStart: new Map(acc.subStart),
1733
+ firstChunkAt: new Map(acc.firstChunkAt),
1734
+ compactionTokens: new Map(acc.compactionTokens),
1735
+ lastPruneTokens: acc.lastPruneTokens,
1736
+ turnFiles: new Map([...acc.turnFiles].map(([turn, files]) => [turn, new Set(files)])),
1737
+ turnSteps: new Map(acc.turnSteps),
1738
+ turnTools: new Map([...acc.turnTools].map(([turn, tools]) => [turn, new Set(tools)])),
1739
+ systemNodes: new Map(acc.systemNodes),
1740
+ },
1741
+ };
1742
+ }
1743
+ /**
1744
+ * Fold a replayed event history into one view.
1745
+ *
1746
+ * Folding is near-linear in the log size: the mutable replay accumulator
1747
+ * appends in place and resolves id-keyed updates through index maps, so a
1748
+ * long persisted session replays without the O(N²) copy-on-write rebuilds a
1749
+ * naive sequential fold would incur. The result is identical to folding
1750
+ * {@link projectEvent} per event in order.
1751
+ * @param events - events in `seq` order.
1752
+ * @returns the folded view.
1753
+ */
1754
+ export function projectEvents(events) {
1755
+ const acc = createReplayAccumulator();
1756
+ for (const event of events)
1757
+ replayProjectEvent(acc, event);
1758
+ return finishReplay(acc);
1759
+ }
1760
+ /**
1761
+ * Fold one process-local assistant-stream chunk frame (session-log v2+ keeps
1762
+ * durable logs settlement-only; live typing rides the `agent/assistant-stream`
1763
+ * agent event). Same first-token anchoring the durable `assistant/chunk` event
1764
+ * used to carry: the first non-empty delta anchors the TTFT and empty
1765
+ * keep-alive deltas do not count. The caller maps the frame's attempt to the
1766
+ * `turn:step` key (the start frame owns turn/step; chunk frames do not).
1767
+ * @param acc - the live replay accumulator.
1768
+ * @param key - the `turn:step` key the attempt's start frame declared.
1769
+ * @param time - the frame's safe-integer timestamp.
1770
+ * @param chunk - the model chunk the frame carries.
1771
+ * @returns whether the accumulator changed (the store stays silent otherwise).
1772
+ */
1773
+ export function applyAssistantStreamChunk(acc, key, time, chunk) {
1774
+ // First-token latency uses the kernel's isTokenDelta rule (a non-empty
1775
+ // text, reasoning, or tool-call fragment counts; block/usage/finish chunks
1776
+ // do not), so live frames and replayed embedded streams time the same
1777
+ // token.
1778
+ if (isTokenDelta(chunk) && !acc.firstChunkAt.has(key)) {
1779
+ acc.firstChunkAt.set(key, time);
1780
+ const started = acc.stepStart.get(key);
1781
+ if (started !== undefined) {
1782
+ acc.stats = {
1783
+ ...acc.stats,
1784
+ ttftMs: acc.stats.ttftMs + Math.max(0, time - started),
1785
+ ttftSteps: acc.stats.ttftSteps + 1,
1786
+ };
1787
+ }
1788
+ }
1789
+ if (chunk.type === 'text-delta') {
1790
+ acc.streaming = appendStreamingTail(acc.streaming, chunk.text);
1791
+ return true;
1792
+ }
1793
+ if (chunk.type === 'reasoning-delta') {
1794
+ acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text);
1795
+ return true;
1796
+ }
1797
+ return false;
1798
+ }
1799
+ /**
1800
+ * Drop the live streaming tails without a settlement (an `agent/assistant-stream`
1801
+ * end frame with an `abandoned` outcome, or a session switch). The next start
1802
+ * frame rebuilds from scratch.
1803
+ * @param acc - the live replay accumulator.
1804
+ * @returns whether any tail text was discarded.
1805
+ */
1806
+ export function clearAssistantStream(acc) {
1807
+ const streamed = acc.streaming !== '' || acc.streamingReasoning !== '';
1808
+ acc.streaming = '';
1809
+ acc.streamingReasoning = '';
1810
+ return streamed;
1811
+ }
1812
+ /**
1813
+ * The append-only flush boundary for a transcript view: the count of entries
1814
+ * no later event can remove. Entries at or beyond this index are mutable and
1815
+ * must stay in the live tree.
1816
+ *
1817
+ * `pending` rows are excluded even though they are not a running tool/retry:
1818
+ * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
1819
+ * `user/message` retirement), and an append-only `<Static>` flush cannot
1820
+ * erase a row that vanishes from the view — the retired row would ghost on
1821
+ * screen until the next source-backed replay. Running commands join the
1822
+ * mutable boundary for the same reason in reverse: `command/done` mutates the
1823
+ * row's state/summary, so a flushed row would keep its stale running mark
1824
+ * until a resize-triggered replay. Everything else (including a completed
1825
+ * tail) is final: later events only APPEND new rows.
1826
+ * @param entries - the view's transcript entries in order.
1827
+ * @returns the count of entries safe to flush (0 for an empty transcript).
1828
+ */
1829
+ export function settledEntryCount(entries) {
1830
+ for (let index = 0; index < entries.length; index++) {
1831
+ const entry = entries[index];
1832
+ if (entry.kind === 'pending')
1833
+ return index;
1834
+ if (entry.kind === 'tool' && entry.state === 'running')
1835
+ return index;
1836
+ if (entry.kind === 'retry' && entry.state === 'running')
1837
+ return index;
1838
+ if (entry.kind === 'command' && entry.state === 'running')
1839
+ return index;
1840
+ if (entry.kind === 'workflow' && entry.state === 'running')
1841
+ return index;
1842
+ }
1843
+ return entries.length;
1844
+ }
1845
+ /**
1846
+ * dscode: mark the turn's final reply so the renderer can close the turn with its
1847
+ * own rule. The upstream transcript keeps no turn boundary of its own.
1848
+ */
1849
+ function dscodeCloseTurn(entries) {
1850
+ const last = entries[entries.length - 1];
1851
+ if (last === undefined || last.kind !== 'assistant' || last.turnEnded === true)
1852
+ return entries;
1853
+ return [...entries.slice(0, -1), { ...last, turnEnded: true }];
1854
+ }
1855
+ /**
1856
+ * dscode: session-bridge relays are plugin messages that must render as visible
1857
+ * user turns (the bridge delivers a peer session's message), so the replay
1858
+ * branch admits them alongside direct human prompts.
1859
+ */
1860
+ function dscodeVisibleRelay(message) {
1861
+ return message.source.kind === 'plugin' && message.source.plugin === 'dscode-session-bridge' && message.source.form === 'relay';
1862
+ }