dsh-code 1.0.7 → 1.3.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 (89) hide show
  1. package/README.en.md +70 -24
  2. package/README.md +71 -25
  3. package/bin/deepseek.mjs +202 -39
  4. package/cordis.patch.yml +13 -4
  5. package/lib/index.mjs +5002 -1061
  6. package/lib/session-query.mjs +3 -2
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-B3orFUYz.mjs} +665 -20
  9. package/lib/types/app.d.ts +120 -63
  10. package/lib/types/attachments.d.ts +16 -7
  11. package/lib/types/authorization-panel.d.ts +3 -3
  12. package/lib/types/git-workflow.d.ts +91 -2
  13. package/lib/types/history.d.ts +10 -0
  14. package/lib/types/i18n.d.ts +39 -0
  15. package/lib/types/index.d.ts +74 -2
  16. package/lib/types/input-split.d.ts +1 -1
  17. package/lib/types/kernel-panels.d.ts +89 -32
  18. package/lib/types/language-panel.d.ts +12 -0
  19. package/lib/types/locales/en.d.ts +498 -0
  20. package/lib/types/locales/zh.d.ts +9 -0
  21. package/lib/types/mentions.d.ts +7 -3
  22. package/lib/types/models.d.ts +14 -0
  23. package/lib/types/panel-accent.d.ts +28 -0
  24. package/lib/types/rainbow.d.ts +69 -0
  25. package/lib/types/render/animations.d.ts +42 -0
  26. package/lib/types/render/inspector.d.ts +26 -0
  27. package/lib/types/render/lines.d.ts +21 -1
  28. package/lib/types/render/markdown.d.ts +1 -1
  29. package/lib/types/render/projection.d.ts +95 -4
  30. package/lib/types/render/status.d.ts +12 -9
  31. package/lib/types/render/text.d.ts +6 -0
  32. package/lib/types/render/usage.d.ts +113 -0
  33. package/lib/types/session-directory.d.ts +42 -1
  34. package/lib/types/session-switch.d.ts +8 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +8 -0
  37. package/lib/types/theme-panel.d.ts +2 -2
  38. package/lib/types/theme.d.ts +271 -52
  39. package/lib/types/update-panel.d.ts +27 -6
  40. package/lib/types/update.d.ts +10 -1
  41. package/lib/types/version.d.ts +6 -3
  42. package/package.json +26 -7
  43. package/src/app.ts +1426 -627
  44. package/src/approval.ts +166 -166
  45. package/src/attachments.ts +65 -19
  46. package/src/authorization-panel.ts +24 -18
  47. package/src/editor-keys.ts +371 -371
  48. package/src/fork.ts +11 -7
  49. package/src/git-workflow.ts +229 -3
  50. package/src/history.ts +14 -0
  51. package/src/i18n.ts +68 -0
  52. package/src/index.ts +503 -128
  53. package/src/input-split.ts +27 -7
  54. package/src/kernel-panels.ts +528 -113
  55. package/src/keyboard.ts +5 -4
  56. package/src/language-panel.ts +53 -0
  57. package/src/locales/en.ts +538 -0
  58. package/src/locales/zh.ts +537 -0
  59. package/src/mentions.ts +8 -4
  60. package/src/models.ts +264 -212
  61. package/src/panel-accent.ts +41 -0
  62. package/src/presets.ts +1 -1
  63. package/src/provider-settings.ts +1 -1
  64. package/src/rainbow.ts +218 -0
  65. package/src/render/animations.ts +104 -6
  66. package/src/render/editor.ts +20 -20
  67. package/src/render/export.ts +116 -95
  68. package/src/render/inspector.ts +42 -0
  69. package/src/render/lines.ts +628 -415
  70. package/src/render/markdown.ts +15 -3
  71. package/src/render/projection.ts +429 -19
  72. package/src/render/status.ts +119 -62
  73. package/src/render/text.ts +15 -0
  74. package/src/render/tool-preview.ts +77 -77
  75. package/src/render/usage.ts +430 -0
  76. package/src/render/width.ts +2 -2
  77. package/src/session-directory.ts +90 -9
  78. package/src/session-query.ts +8 -4
  79. package/src/session-switch.ts +14 -0
  80. package/src/startup.ts +3 -3
  81. package/src/store.ts +19 -1
  82. package/src/subagents.ts +229 -229
  83. package/src/terminal-title.ts +22 -5
  84. package/src/theme-panel.ts +17 -21
  85. package/src/theme.ts +281 -33
  86. package/src/update-panel.ts +148 -31
  87. package/src/update.ts +19 -3
  88. package/src/version.ts +63 -20
  89. package/src/whale-glyph.ts +23 -23
@@ -63,6 +63,12 @@ export interface UserEntry {
63
63
  images?: readonly ImageBlock['attachment'][]
64
64
  /** Durable file references carried by this prompt (0.1.5 file blocks). */
65
65
  files?: readonly FileAttachmentRef[]
66
+ /**
67
+ * How the prompt reached the agent, when it did not arrive as an ordinary
68
+ * submission: `queued` waited for this turn, `steered` joined it mid-flight.
69
+ * Absent for a prompt typed straight into an idle composer.
70
+ */
71
+ delivery?: 'queued' | 'steered'
66
72
  }
67
73
 
68
74
  /** One user message waiting in the agent inbox (the web's queued-message row). */
@@ -122,6 +128,169 @@ export interface ToolEntry {
122
128
  * result lands and only when something renderable exists.
123
129
  */
124
130
  detail: ToolDetail | undefined
131
+ /**
132
+ * Nested PTC sub-dispatches (`run_code`) in start order, bounded to the
133
+ * newest {@link MAX_TOOL_SUB_DISPATCHES} rows.
134
+ */
135
+ subs: readonly ToolSubDispatch[]
136
+ /** Sub-dispatches evicted from the bounded window. */
137
+ subsDropped: number
138
+ }
139
+
140
+ /**
141
+ * One nested PTC sub-dispatch under a `run_code` parent call: the durable
142
+ * `tool/ptc-dispatch-start`/`tool/ptc-dispatch` pair folded to one bounded
143
+ * row (upstream contract: pair by `subCallId`, every start settles, and the
144
+ * settle carries `tool/result`'s own vocabulary).
145
+ */
146
+ export interface ToolSubDispatch {
147
+ /** Opaque sub-call id pairing the start with its settle. */
148
+ subCallId: string
149
+ /** Sub-call tool name. */
150
+ name: string
151
+ /** Bounded arguments preview. */
152
+ preview: string
153
+ /** Lifecycle; `running` until the paired settle lands. */
154
+ state: 'running' | 'done' | 'error'
155
+ /** Bounded first text block of the settle content, '' until it lands. */
156
+ summary: string
157
+ /** Wall-clock duration (settle − start), 0 while running. */
158
+ durationMs: number
159
+ }
160
+
161
+ /** Bounded sub-dispatch window per tool card (display budget only). */
162
+ export const MAX_TOOL_SUB_DISPATCHES = 12
163
+
164
+ /** Bounded member window per workflow run card (display budget only). */
165
+ export const MAX_WORKFLOW_MEMBERS = 12
166
+
167
+ /** One workflow member (an `agent()` call inside a `workflow` script). */
168
+ export interface WorkflowMember {
169
+ /** Member sequence within the run (the agent-start/agent-end pairing key). */
170
+ seq: number
171
+ /** Display label. */
172
+ label: string
173
+ /** Declared phase title, '' when none. */
174
+ phase: string
175
+ /** Child session id (cross-links the subagent feed's rows). */
176
+ childId: string
177
+ /** Settlement; `running` until the paired `tool-workflow/agent-end`. */
178
+ outcome: 'running' | 'completed' | 'failed' | 'cancelled'
179
+ }
180
+
181
+ /**
182
+ * One durable workflow run (the `tool-workflow/*` record a `workflow` or
183
+ * `ralph` tool appends to the parent session): run identity plus its
184
+ * bounded member list, live until `tool-workflow/run-end` settles.
185
+ */
186
+ export interface WorkflowEntry {
187
+ kind: 'workflow'
188
+ /** Stable run identity shared by every event of the run. */
189
+ runId: string
190
+ /** Display name of the run. */
191
+ name: string
192
+ /** Members in sequence order, bounded to the newest window. */
193
+ members: readonly WorkflowMember[]
194
+ /** Members evicted from the bounded window. */
195
+ membersDropped: number
196
+ /** Run settlement; `running` until `tool-workflow/run-end`. */
197
+ state: 'running' | 'completed' | 'cancelled' | 'error'
198
+ }
199
+
200
+ /** Payload of `tool-workflow/run-start` (dsh-tool-workflow's map merge). */
201
+ interface WorkflowRunStartData {
202
+ runId: string
203
+ name: string
204
+ }
205
+
206
+ /** Payload of `tool-workflow/agent-start`. */
207
+ interface WorkflowAgentStartData {
208
+ runId: string
209
+ seq: number
210
+ label: string
211
+ phase?: string
212
+ childId: string
213
+ }
214
+
215
+ /** Payload of `tool-workflow/agent-end`. */
216
+ interface WorkflowAgentEndData {
217
+ runId: string
218
+ seq: number
219
+ outcome: 'completed' | 'failed' | 'cancelled'
220
+ }
221
+
222
+ /** Payload of `tool-workflow/run-end`. */
223
+ interface WorkflowRunEndData {
224
+ runId: string
225
+ stopReason: 'completed' | 'cancelled' | 'error'
226
+ }
227
+
228
+ /** Append one member to a run entry, evicting past the bounded window. */
229
+ function pushWorkflowMember(entry: WorkflowEntry, member: WorkflowMember): WorkflowEntry {
230
+ if (entry.members.length >= MAX_WORKFLOW_MEMBERS) {
231
+ return { ...entry, members: [...entry.members.slice(1), member], membersDropped: entry.membersDropped + 1 }
232
+ }
233
+ return { ...entry, members: [...entry.members, member] }
234
+ }
235
+
236
+ /** Settle one member by its sequence number (pure; unmatched stays a no-op). */
237
+ function settleWorkflowMember(entry: WorkflowEntry, data: WorkflowAgentEndData): WorkflowEntry {
238
+ let paired = false
239
+ const members = entry.members.map(member => {
240
+ if (member.seq !== data.seq) return member
241
+ paired = true
242
+ return { ...member, outcome: data.outcome }
243
+ })
244
+ return paired ? { ...entry, members } : entry
245
+ }
246
+
247
+ /**
248
+ * Payload of `tool/ptc-dispatch-start` (`@deepseek-ai/dsh-tools`'s
249
+ * SessionEventMap merge — the bundle does not depend on that package, so
250
+ * the fold guards the discriminator by string instead of by type).
251
+ */
252
+ interface PtcDispatchStartData {
253
+ rootCallId: string
254
+ subCallId: string
255
+ name: string
256
+ arguments: unknown
257
+ }
258
+
259
+ /** Payload of `tool/ptc-dispatch` (a start plus its settled outcome). */
260
+ interface PtcDispatchData extends PtcDispatchStartData {
261
+ isError: boolean
262
+ content: readonly ContentBlock[]
263
+ }
264
+
265
+ /** JSON-stringify one sub-dispatch argument value for the preview helper. */
266
+ function subDispatchArguments(arguments_: unknown): string {
267
+ if (typeof arguments_ === 'string') return arguments_
268
+ try {
269
+ return JSON.stringify(arguments_) ?? ''
270
+ } catch {
271
+ return ''
272
+ }
273
+ }
274
+
275
+ /** Append one running sub-dispatch under its parent, evicting past the cap. */
276
+ function pushSubDispatch(entry: ToolEntry, sub: ToolSubDispatch): ToolEntry {
277
+ if (entry.subs.length >= MAX_TOOL_SUB_DISPATCHES) {
278
+ return { ...entry, subs: [...entry.subs.slice(1), sub], subsDropped: entry.subsDropped + 1 }
279
+ }
280
+ return { ...entry, subs: [...entry.subs, sub] }
281
+ }
282
+
283
+ /** Fold one settled sub-dispatch into its matching row (pure). */
284
+ function settleSubDispatch(entry: ToolEntry, data: PtcDispatchData, summary: string, durationMs: number): ToolEntry {
285
+ let paired = false
286
+ const subs = entry.subs.map(sub => {
287
+ if (sub.subCallId !== data.subCallId) return sub
288
+ paired = true
289
+ return { ...sub, state: data.isError === true ? 'error' as const : 'done' as const, summary, durationMs }
290
+ })
291
+ // A settle without a live row (the sub left the bounded window, or the
292
+ // parent entry was never seen) stays a provable no-op.
293
+ return paired ? { ...entry, subs } : entry
125
294
  }
126
295
 
127
296
  /** One slash-command execution dispatched through `ctx.commands`. */
@@ -194,7 +363,7 @@ export interface FilesEntry {
194
363
  }
195
364
 
196
365
  /** Ordered transcript items the renderer draws. */
197
- export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
366
+ export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry | WorkflowEntry
198
367
 
199
368
  /** The live goal the status line badges, folded from `goal/change`. */
200
369
  export interface GoalFold {
@@ -209,14 +378,21 @@ export interface GoalFold {
209
378
  blocked: string
210
379
  }
211
380
 
212
- /** Cumulative token accounting folded from `assistant/message` usage reports. */
381
+ /**
382
+ * Cumulative token accounting folded from `assistant/message` usage reports.
383
+ * The buckets are disjoint and mirror the provider's report, so the prompt
384
+ * side is never double counted: reasoning tokens are already inside
385
+ * `outputTokens`, and cache reads are never folded into the uncached input.
386
+ */
213
387
  export interface UsageTotals {
214
- /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
215
- inputTokens: number
388
+ /** Prompt-side tokens billed outside the cache. */
389
+ uncachedInputTokens: number
216
390
  /** Completion-side tokens over the whole log. */
217
391
  outputTokens: number
218
392
  /** Cache-read tokens over the whole log (0 when the adapter reports none). */
219
393
  cacheReadTokens: number
394
+ /** Cache-write tokens over the whole log (0 when the adapter reports none). */
395
+ cacheWriteTokens: number
220
396
  }
221
397
 
222
398
  /**
@@ -408,6 +584,14 @@ export interface TranscriptView {
408
584
  * coordinates later removals resolve against.
409
585
  */
410
586
  pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
587
+ /**
588
+ * In-flight inbox messages by the list they were inserted into, kept until
589
+ * the durable user message that claims them lands. The claim itself is a
590
+ * plain splice that empties the pending row first, so this map — not
591
+ * {@link pending} — is what lets a settled prompt say it was queued or
592
+ * steered rather than typed into an idle composer.
593
+ */
594
+ claimOrigin: ReadonlyMap<string, 'next-turn' | 'next-step'>
411
595
  /**
412
596
  * Fold-internal timing anchors, never rendered: open step and tool-call
413
597
  * start timestamps the next `assistant/message` / `tool/result` resolves
@@ -419,6 +603,8 @@ export interface TranscriptView {
419
603
  readonly anchors: {
420
604
  stepStart: Map<string, number>
421
605
  toolStart: Map<string, number>
606
+ /** Open PTC sub-dispatch starts by `subCallId` (duration anchors). */
607
+ subStart: Map<string, number>
422
608
  firstChunkAt: Map<string, number>
423
609
  compactionTokens: Map<string, number>
424
610
  lastPruneTokens: number
@@ -482,6 +668,7 @@ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['a
482
668
  return {
483
669
  stepStart: new Map(anchors.stepStart),
484
670
  toolStart: new Map(anchors.toolStart),
671
+ subStart: new Map(anchors.subStart),
485
672
  firstChunkAt: new Map(anchors.firstChunkAt),
486
673
  compactionTokens: new Map(anchors.compactionTokens),
487
674
  lastPruneTokens: anchors.lastPruneTokens,
@@ -576,8 +763,9 @@ export function createTranscriptView(): TranscriptView {
576
763
  goal: undefined,
577
764
  schedules: [],
578
765
  pending: { 'next-turn': [], 'next-step': [] },
579
- 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: '' },
580
- anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map(), systemNodes: new Map() },
766
+ claimOrigin: new Map(),
767
+ 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: '' },
768
+ 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() },
581
769
  }
582
770
  }
583
771
 
@@ -622,6 +810,18 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
622
810
  pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
623
811
  entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
624
812
  }
813
+ // How this message reached the agent. The claim that precedes this event
814
+ // is a plain splice (no `canceled` outcome), so it has already emptied
815
+ // the pending row above; `claimOrigin` is what survives to say whether
816
+ // the prompt was queued for this turn or steered into it.
817
+ const origin = view.claimOrigin.get(message.id)
818
+ let claimOrigin = view.claimOrigin
819
+ if (origin !== undefined) {
820
+ const next = new Map(claimOrigin)
821
+ next.delete(message.id)
822
+ claimOrigin = next
823
+ }
824
+ const delivery = origin === undefined ? {} : { delivery: origin === 'next-turn' ? 'queued' as const : 'steered' as const }
625
825
  // Injected context (plugin/model-continuation sources) stays collapsed
626
826
  // to a bounded notice row, exactly like collapsed transcript context
627
827
  // elsewhere in the product; only direct human prompts render in full.
@@ -632,7 +832,8 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
632
832
  return {
633
833
  ...view,
634
834
  pending,
635
- entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
835
+ claimOrigin,
836
+ entries: [...entries, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
636
837
  stats: {
637
838
  ...view.stats,
638
839
  contextSegments: {
@@ -649,6 +850,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
649
850
  return {
650
851
  ...view,
651
852
  pending,
853
+ claimOrigin,
652
854
  entries,
653
855
  stats: {
654
856
  ...view.stats,
@@ -663,7 +865,8 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
663
865
  return {
664
866
  ...view,
665
867
  pending,
666
- entries: [...entries, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
868
+ claimOrigin,
869
+ entries: [...entries, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
667
870
  stats: {
668
871
  ...view.stats,
669
872
  contextSegments: {
@@ -682,6 +885,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
682
885
  return {
683
886
  ...view,
684
887
  pending,
888
+ claimOrigin,
685
889
  entries: [...entries, { kind: 'user', text: summary, notice: true }],
686
890
  stats: {
687
891
  ...view.stats,
@@ -696,9 +900,29 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
696
900
  // The durable inbox mutation (web queue-mirror contract, event-sourced):
697
901
  // removals drop the projected rows at their inbox coordinates, inserted
698
902
  // messages gain a pending row at their log position.
699
- const { target, start, removedCount = 0, inserted } = event.data
903
+ const { target, start, removedCount = 0, inserted, outcome } = event.data
700
904
  const ids = view.pending[target]
701
905
  const removed = ids.slice(start, start + removedCount)
906
+ // Which list each in-flight message came from, so the durable user
907
+ // message it later becomes can say how it was delivered. Only messages
908
+ // that arrived while a turn was already running count: `followup` is
909
+ // the ordinary submission path too, so an idle submission lands in
910
+ // next-turn exactly like a queued one and must stay unmarked. A claim
911
+ // (no `outcome`) keeps the entry because the user message is still
912
+ // coming; a real cancellation retires it.
913
+ const inFlight = view.busy
914
+ let claimOrigin: ReadonlyMap<string, 'next-turn' | 'next-step'> = view.claimOrigin
915
+ let mutableOrigins: Map<string, 'next-turn' | 'next-step'> | undefined
916
+ const remember = (id: string): void => {
917
+ mutableOrigins ??= new Map(claimOrigin)
918
+ mutableOrigins.set(id, target)
919
+ claimOrigin = mutableOrigins
920
+ }
921
+ if (outcome === 'canceled' && removed.length > 0) {
922
+ const next = new Map(claimOrigin)
923
+ for (const id of removed) next.delete(id)
924
+ claimOrigin = next
925
+ }
702
926
  // In-place upstream semantics: the kernel's authoritative fold is
703
927
  // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
704
928
  // AT the splice position (prepend/replace shapes), never at the tail.
@@ -717,6 +941,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
717
941
  !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
718
942
  }
719
943
  for (const message of inserted) {
944
+ if (inFlight) remember(message.id)
720
945
  entries = [...entries, {
721
946
  kind: 'pending',
722
947
  messageId: message.id,
@@ -726,7 +951,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
726
951
  ...filesOf(message.content).length === 0 ? {} : { files: filesOf(message.content) },
727
952
  }]
728
953
  }
729
- return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
954
+ return { ...view, entries, claimOrigin, pending: { ...view.pending, [target]: nextIds } }
730
955
  }
731
956
  case 'system/message': {
732
957
  // Session-log v3 carries the system prompt as surface nodes (the
@@ -810,18 +1035,25 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
810
1035
  const totals = stats.usage
811
1036
  const text = textOf(event.data.message.content)
812
1037
  const reasoning = reasoningOf(event.data.message.content)
1038
+ // A tool-only step settles with no text, no reasoning, and (unless the
1039
+ // attempt was interrupted) nothing to render: appending an entry would
1040
+ // create an invisible zero-line card that pads the inspector's ←→
1041
+ // walk and an empty block in /export. The event still updates timing
1042
+ // and usage above; only the transcript entry is skipped.
1043
+ const renderable = text !== '' || reasoning !== '' || event.data.interrupted === true
813
1044
  return {
814
1045
  ...view,
815
1046
  streaming: '',
816
1047
  streamingReasoning: '',
817
- entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }],
1048
+ ...(renderable ? { entries: [...view.entries, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined }] } : {}),
818
1049
  stats: {
819
1050
  ...stats,
820
1051
  llmMs: stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
821
1052
  usage: usage === undefined ? totals : {
822
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1053
+ uncachedInputTokens: totals.uncachedInputTokens + usage.inputTokens,
823
1054
  outputTokens: totals.outputTokens + usage.outputTokens,
824
1055
  cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1056
+ cacheWriteTokens: totals.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
825
1057
  },
826
1058
  lastPromptTokens: usage === undefined ? stats.lastPromptTokens
827
1059
  : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
@@ -862,6 +1094,8 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
862
1094
  state: 'running',
863
1095
  summary: '',
864
1096
  detail: undefined,
1097
+ subs: [],
1098
+ subsDropped: 0,
865
1099
  }],
866
1100
  stats: {
867
1101
  ...view.stats,
@@ -1165,8 +1399,86 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
1165
1399
  })
1166
1400
  return { ...view, entries }
1167
1401
  }
1168
- default:
1402
+ default: {
1403
+ // `tool/ptc-dispatch*` live in @deepseek-ai/dsh-tools' map merge,
1404
+ // which this bundle does not depend on; the string guards keep the
1405
+ // fold decoupled while following the upstream contract exactly (pair
1406
+ // by subCallId; every start settles; settle speaks tool/result's own
1407
+ // vocabulary). Log-only upstream: deriveMessages ignores them, so the
1408
+ // sub rows are pure display state on the parent run_code card.
1409
+ const type = event.type as string
1410
+ if (type === 'tool/ptc-dispatch-start') {
1411
+ const data = event.data as PtcDispatchStartData
1412
+ view.anchors.subStart.set(data.subCallId, event.time)
1413
+ const entries = view.entries.map(entry => entry.kind !== 'tool' || entry.callId !== data.rootCallId
1414
+ ? entry
1415
+ : pushSubDispatch(entry, {
1416
+ subCallId: data.subCallId,
1417
+ name: data.name,
1418
+ preview: toolArgumentsPreview(subDispatchArguments(data.arguments), data.name),
1419
+ state: 'running',
1420
+ summary: '',
1421
+ durationMs: 0,
1422
+ }))
1423
+ return { ...view, entries }
1424
+ }
1425
+ if (type === 'tool/ptc-dispatch') {
1426
+ const data = event.data as PtcDispatchData
1427
+ const started = view.anchors.subStart.get(data.subCallId)
1428
+ view.anchors.subStart.delete(data.subCallId)
1429
+ const summary = boundContextSummary(textOf(data.content))
1430
+ const durationMs = started === undefined ? 0 : Math.max(0, event.time - started)
1431
+ const entries = view.entries.map(entry => entry.kind !== 'tool' || entry.callId !== data.rootCallId
1432
+ ? entry
1433
+ : settleSubDispatch(entry, data, summary, durationMs))
1434
+ return { ...view, entries }
1435
+ }
1436
+ // The durable workflow record (`tool-workflow/*`, appended by the
1437
+ // workflow/ralph tools): one bounded card per run, members paired by
1438
+ // their sequence number, the run settling on run-end.
1439
+ if (type === 'tool-workflow/run-start') {
1440
+ const data = event.data as WorkflowRunStartData
1441
+ return {
1442
+ ...view,
1443
+ entries: [...view.entries, {
1444
+ kind: 'workflow',
1445
+ runId: data.runId,
1446
+ name: data.name,
1447
+ members: [],
1448
+ membersDropped: 0,
1449
+ state: 'running',
1450
+ }],
1451
+ }
1452
+ }
1453
+ if (type === 'tool-workflow/agent-start') {
1454
+ const data = event.data as WorkflowAgentStartData
1455
+ const entries = view.entries.map(entry => entry.kind !== 'workflow' || entry.runId !== data.runId
1456
+ ? entry
1457
+ : pushWorkflowMember(entry, {
1458
+ seq: data.seq,
1459
+ label: data.label,
1460
+ phase: data.phase ?? '',
1461
+ childId: data.childId,
1462
+ outcome: 'running',
1463
+ }))
1464
+ return { ...view, entries }
1465
+ }
1466
+ if (type === 'tool-workflow/agent-end') {
1467
+ const data = event.data as WorkflowAgentEndData
1468
+ const entries = view.entries.map(entry => entry.kind !== 'workflow' || entry.runId !== data.runId
1469
+ ? entry
1470
+ : settleWorkflowMember(entry, data))
1471
+ return { ...view, entries }
1472
+ }
1473
+ if (type === 'tool-workflow/run-end') {
1474
+ const data = event.data as WorkflowRunEndData
1475
+ const entries = view.entries.map(entry => entry.kind !== 'workflow' || entry.runId !== data.runId
1476
+ ? entry
1477
+ : { ...entry, state: data.stopReason })
1478
+ return { ...view, entries }
1479
+ }
1169
1480
  return view
1481
+ }
1170
1482
  }
1171
1483
  }
1172
1484
 
@@ -1201,11 +1513,15 @@ export interface ReplayAccumulator {
1201
1513
  retryIndex: Map<string, number[]>
1202
1514
  /** messageId → every index into `entries` holding a `pending` row with that id. */
1203
1515
  pendingIndex: Map<string, number[]>
1516
+ /** runId → every index into `entries` holding a `workflow` row with that id. */
1517
+ workflowIndex: Map<string, number[]>
1204
1518
  /** Tombstone count; zero means `entries` is already the final array. */
1205
1519
  removedCount: number
1206
1520
  /** Mutable inbox id lists, mirroring `view.pending` order per target. */
1207
1521
  pendingTurn: string[]
1208
1522
  pendingStep: string[]
1523
+ /** Mutable mirror of `view.claimOrigin` (see the reducer's field doc). */
1524
+ claimOrigin: Map<string, 'next-turn' | 'next-step'>
1209
1525
  streaming: string
1210
1526
  streamingReasoning: string
1211
1527
  todos: readonly TodoItem[]
@@ -1224,6 +1540,8 @@ export interface ReplayAccumulator {
1224
1540
  stats: TranscriptStats
1225
1541
  stepStart: Map<string, number>
1226
1542
  toolStart: Map<string, number>
1543
+ /** Open PTC sub-dispatch starts by `subCallId` (duration anchors). */
1544
+ subStart: Map<string, number>
1227
1545
  firstChunkAt: Map<string, number>
1228
1546
  compactionTokens: Map<string, number>
1229
1547
  lastPruneTokens: number
@@ -1244,9 +1562,11 @@ export function createReplayAccumulator(): ReplayAccumulator {
1244
1562
  commandIndex: new Map(),
1245
1563
  retryIndex: new Map(),
1246
1564
  pendingIndex: new Map(),
1565
+ workflowIndex: new Map(),
1247
1566
  removedCount: 0,
1248
1567
  pendingTurn: [],
1249
1568
  pendingStep: [],
1569
+ claimOrigin: new Map(),
1250
1570
  streaming: '',
1251
1571
  streamingReasoning: '',
1252
1572
  todos: [],
@@ -1261,9 +1581,10 @@ export function createReplayAccumulator(): ReplayAccumulator {
1261
1581
  sandbox: '',
1262
1582
  goal: undefined,
1263
1583
  schedules: [],
1264
- 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: '' },
1584
+ 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: '' },
1265
1585
  stepStart: new Map(),
1266
1586
  toolStart: new Map(),
1587
+ subStart: new Map(),
1267
1588
  firstChunkAt: new Map(),
1268
1589
  compactionTokens: new Map(),
1269
1590
  lastPruneTokens: 0,
@@ -1394,8 +1715,14 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1394
1715
  const text = textOf(message.content)
1395
1716
  const images = imagesOf(message.content)
1396
1717
  const files = filesOf(message.content)
1718
+ // Same delivery-origin rule as the reducer: the claim splice that
1719
+ // precedes this event never carried the origin, so `claimOrigin` is the
1720
+ // only surviving record of how the prompt was submitted.
1721
+ const origin = acc.claimOrigin.get(message.id)
1722
+ const delivery = origin === undefined ? {} : { delivery: origin === 'next-turn' ? 'queued' as const : 'steered' as const }
1723
+ acc.claimOrigin.delete(message.id)
1397
1724
  if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
1398
- appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1725
+ appendReplayEntry(acc, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1399
1726
  acc.stats = {
1400
1727
  ...acc.stats,
1401
1728
  contextSegments: {
@@ -1432,12 +1759,15 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1432
1759
  return true
1433
1760
  }
1434
1761
  case 'agent/inbox/spliced': {
1435
- const { target, start, removedCount = 0, inserted } = event.data
1762
+ const { target, start, removedCount = 0, inserted, outcome } = event.data
1436
1763
  const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1437
1764
  const removed = ids.slice(start, start + removedCount)
1438
1765
  acc.ops += removed.length
1439
1766
  ids.splice(start, removedCount)
1440
1767
  acc.ops += removed.length
1768
+ // A claim keeps the origin for the user message still to come; a real
1769
+ // cancellation retires it with the row.
1770
+ if (outcome === 'canceled') for (const id of removed) acc.claimOrigin.delete(id)
1441
1771
  for (const id of removed) {
1442
1772
  const list = acc.pendingIndex.get(id)
1443
1773
  if (list === undefined) continue
@@ -1454,6 +1784,9 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1454
1784
  // later inbox event.
1455
1785
  ids.splice(start, 0, ...inserted.map(message => message.id))
1456
1786
  for (const message of inserted) {
1787
+ // Same rule as the reducer: only a submission that arrived while a
1788
+ // turn was running is "queued"/"steered" rather than ordinary.
1789
+ if (acc.busy) acc.claimOrigin.set(message.id, target)
1457
1790
  const images = imagesOf(message.content)
1458
1791
  const files = filesOf(message.content)
1459
1792
  appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
@@ -1530,14 +1863,19 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1530
1863
  const reasoning = reasoningOf(event.data.message.content)
1531
1864
  acc.streaming = ''
1532
1865
  acc.streamingReasoning = ''
1533
- appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1866
+ // Same zero-line guard as the live fold: tool-only settlements carry
1867
+ // timing/usage but no renderable transcript entry.
1868
+ if (text !== '' || reasoning !== '' || event.data.interrupted === true) {
1869
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning, interrupted: event.data.interrupted === true ? true : undefined })
1870
+ }
1534
1871
  acc.stats = {
1535
1872
  ...acc.stats,
1536
1873
  llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1537
1874
  usage: usage === undefined ? totals : {
1538
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1875
+ uncachedInputTokens: totals.uncachedInputTokens + usage.inputTokens,
1539
1876
  outputTokens: totals.outputTokens + usage.outputTokens,
1540
1877
  cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1878
+ cacheWriteTokens: totals.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
1541
1879
  },
1542
1880
  lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1543
1881
  : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
@@ -1569,6 +1907,8 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1569
1907
  state: 'running',
1570
1908
  summary: '',
1571
1909
  detail: undefined,
1910
+ subs: [],
1911
+ subsDropped: 0,
1572
1912
  })
1573
1913
  indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1574
1914
  acc.stats = {
@@ -1804,8 +2144,75 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1804
2144
  updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1805
2145
  return true
1806
2146
  }
1807
- default:
2147
+ default: {
2148
+ // PTC sub-dispatch pair, string-guarded like the live fold above; the
2149
+ // parent run_code row is addressed through the same call-id index the
2150
+ // tool/result case uses.
2151
+ const type = event.type as string
2152
+ if (type === 'tool/ptc-dispatch-start') {
2153
+ const data = event.data as PtcDispatchStartData
2154
+ acc.subStart.set(data.subCallId, event.time)
2155
+ updateReplayById<ToolEntry>(acc, acc.toolIndex, data.rootCallId, entry => entry.callId === data.rootCallId, entry =>
2156
+ pushSubDispatch(entry, {
2157
+ subCallId: data.subCallId,
2158
+ name: data.name,
2159
+ preview: toolArgumentsPreview(subDispatchArguments(data.arguments), data.name),
2160
+ state: 'running',
2161
+ summary: '',
2162
+ durationMs: 0,
2163
+ }))
2164
+ return true
2165
+ }
2166
+ if (type === 'tool/ptc-dispatch') {
2167
+ const data = event.data as PtcDispatchData
2168
+ const started = acc.subStart.get(data.subCallId)
2169
+ acc.subStart.delete(data.subCallId)
2170
+ const summary = boundContextSummary(textOf(data.content))
2171
+ const durationMs = started === undefined ? 0 : Math.max(0, event.time - started)
2172
+ updateReplayById<ToolEntry>(acc, acc.toolIndex, data.rootCallId, entry => entry.callId === data.rootCallId, entry =>
2173
+ settleSubDispatch(entry, data, summary, durationMs))
2174
+ return true
2175
+ }
2176
+ // Durable workflow record, string-guarded like the live fold above.
2177
+ if (type === 'tool-workflow/run-start') {
2178
+ const data = event.data as WorkflowRunStartData
2179
+ appendReplayEntry(acc, {
2180
+ kind: 'workflow',
2181
+ runId: data.runId,
2182
+ name: data.name,
2183
+ members: [],
2184
+ membersDropped: 0,
2185
+ state: 'running',
2186
+ })
2187
+ indexList(acc.workflowIndex, data.runId).push(acc.entries.length - 1)
2188
+ return true
2189
+ }
2190
+ if (type === 'tool-workflow/agent-start') {
2191
+ const data = event.data as WorkflowAgentStartData
2192
+ updateReplayById<WorkflowEntry>(acc, acc.workflowIndex, data.runId, entry => entry.runId === data.runId, entry =>
2193
+ pushWorkflowMember(entry, {
2194
+ seq: data.seq,
2195
+ label: data.label,
2196
+ phase: data.phase ?? '',
2197
+ childId: data.childId,
2198
+ outcome: 'running',
2199
+ }))
2200
+ return true
2201
+ }
2202
+ if (type === 'tool-workflow/agent-end') {
2203
+ const data = event.data as WorkflowAgentEndData
2204
+ updateReplayById<WorkflowEntry>(acc, acc.workflowIndex, data.runId, entry => entry.runId === data.runId, entry =>
2205
+ settleWorkflowMember(entry, data))
2206
+ return true
2207
+ }
2208
+ if (type === 'tool-workflow/run-end') {
2209
+ const data = event.data as WorkflowRunEndData
2210
+ updateReplayById<WorkflowEntry>(acc, acc.workflowIndex, data.runId, entry => entry.runId === data.runId, entry =>
2211
+ ({ ...entry, state: data.stopReason }))
2212
+ return true
2213
+ }
1808
2214
  return false
2215
+ }
1809
2216
  }
1810
2217
  }
1811
2218
 
@@ -1857,12 +2264,14 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
1857
2264
  goal: acc.goal,
1858
2265
  schedules: acc.schedules,
1859
2266
  pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
2267
+ claimOrigin: new Map(acc.claimOrigin),
1860
2268
  stats: acc.stats,
1861
2269
  // Handed-out views get their own anchors snapshot: the accumulator keeps
1862
2270
  // folding its live containers, and no consumer may observe that.
1863
2271
  anchors: {
1864
2272
  stepStart: new Map(acc.stepStart),
1865
2273
  toolStart: new Map(acc.toolStart),
2274
+ subStart: new Map(acc.subStart),
1866
2275
  firstChunkAt: new Map(acc.firstChunkAt),
1867
2276
  compactionTokens: new Map(acc.compactionTokens),
1868
2277
  lastPruneTokens: acc.lastPruneTokens,
@@ -1969,6 +2378,7 @@ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1969
2378
  if (entry.kind === 'tool' && entry.state === 'running') return index
1970
2379
  if (entry.kind === 'retry' && entry.state === 'running') return index
1971
2380
  if (entry.kind === 'command' && entry.state === 'running') return index
2381
+ if (entry.kind === 'workflow' && entry.state === 'running') return index
1972
2382
  }
1973
2383
  return entries.length
1974
2384
  }