dsh-code 1.0.6 → 1.2.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 (86) hide show
  1. package/README.en.md +123 -26
  2. package/README.md +124 -27
  3. package/bin/deepseek.mjs +283 -35
  4. package/cordis.patch.yml +97 -0
  5. package/lib/index.mjs +5008 -881
  6. package/lib/session-query.mjs +150 -0
  7. package/lib/startup.mjs +4 -4
  8. package/lib/{theme-DCT8Y2xf.mjs → theme-7u5Qo3dF.mjs} +657 -20
  9. package/lib/types/app.d.ts +106 -62
  10. package/lib/types/authorization-panel.d.ts +3 -3
  11. package/lib/types/git-workflow.d.ts +91 -2
  12. package/lib/types/i18n.d.ts +39 -0
  13. package/lib/types/index.d.ts +100 -1
  14. package/lib/types/input-split.d.ts +1 -1
  15. package/lib/types/kernel-panels.d.ts +107 -29
  16. package/lib/types/language-panel.d.ts +12 -0
  17. package/lib/types/locales/en.d.ts +450 -0
  18. package/lib/types/locales/zh.d.ts +9 -0
  19. package/lib/types/mentions.d.ts +7 -3
  20. package/lib/types/models.d.ts +14 -0
  21. package/lib/types/panel-accent.d.ts +28 -0
  22. package/lib/types/rainbow.d.ts +69 -0
  23. package/lib/types/render/animations.d.ts +42 -0
  24. package/lib/types/render/editor.d.ts +4 -3
  25. package/lib/types/render/ime-cursor.d.ts +60 -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 +130 -4
  30. package/lib/types/render/status.d.ts +9 -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 +17 -0
  34. package/lib/types/session-query.d.ts +92 -0
  35. package/lib/types/startup.d.ts +1 -1
  36. package/lib/types/terminal-title.d.ts +66 -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 +49 -0
  40. package/lib/types/update.d.ts +75 -0
  41. package/lib/types/version.d.ts +4 -3
  42. package/package.json +246 -90
  43. package/src/app.ts +1369 -509
  44. package/src/approval.ts +166 -166
  45. package/src/authorization-panel.ts +19 -16
  46. package/src/editor-keys.ts +371 -371
  47. package/src/git-workflow.ts +229 -3
  48. package/src/i18n.ts +68 -0
  49. package/src/index.ts +534 -80
  50. package/src/input-split.ts +3 -3
  51. package/src/internals.ts +5 -0
  52. package/src/kernel-panels.ts +554 -86
  53. package/src/keyboard.ts +5 -4
  54. package/src/language-panel.ts +53 -0
  55. package/src/locales/en.ts +489 -0
  56. package/src/locales/zh.ts +488 -0
  57. package/src/mentions.ts +8 -4
  58. package/src/models.ts +264 -212
  59. package/src/panel-accent.ts +41 -0
  60. package/src/presets.ts +1 -1
  61. package/src/provider-settings.ts +1 -1
  62. package/src/rainbow.ts +208 -0
  63. package/src/render/animations.ts +104 -6
  64. package/src/render/editor.ts +25 -24
  65. package/src/render/export.ts +116 -95
  66. package/src/render/ime-cursor.ts +147 -0
  67. package/src/render/inspector.ts +42 -0
  68. package/src/render/lines.ts +628 -415
  69. package/src/render/markdown.ts +15 -3
  70. package/src/render/projection.ts +572 -21
  71. package/src/render/status.ts +59 -39
  72. package/src/render/text.ts +14 -0
  73. package/src/render/tool-preview.ts +77 -77
  74. package/src/render/usage.ts +430 -0
  75. package/src/render/width.ts +2 -2
  76. package/src/session-directory.ts +8 -6
  77. package/src/session-query.ts +239 -0
  78. package/src/startup.ts +3 -3
  79. package/src/subagents.ts +229 -229
  80. package/src/terminal-title.ts +190 -0
  81. package/src/theme-panel.ts +17 -21
  82. package/src/theme.ts +281 -33
  83. package/src/update-panel.ts +256 -0
  84. package/src/update.ts +126 -0
  85. package/src/version.ts +58 -20
  86. package/src/whale-glyph.ts +23 -23
@@ -23,6 +23,7 @@ import type {} from '@deepseek-ai/dsh-goal'
23
23
  import type {} from '@deepseek-ai/dsh-llm-retry'
24
24
  import type {} from '@deepseek-ai/dsh-plan-mode'
25
25
  import type {} from '@deepseek-ai/dsh-permission-presets'
26
+ import type {} from '@deepseek-ai/dsh-schedule'
26
27
  import type {} from '@deepseek-ai/dsh-sandbox-policy'
27
28
  import type {} from '@deepseek-ai/dsh-session-title'
28
29
  // The subagent package's durable catalog event joins the union the same way
@@ -62,6 +63,12 @@ export interface UserEntry {
62
63
  images?: readonly ImageBlock['attachment'][]
63
64
  /** Durable file references carried by this prompt (0.1.5 file blocks). */
64
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'
65
72
  }
66
73
 
67
74
  /** One user message waiting in the agent inbox (the web's queued-message row). */
@@ -121,6 +128,169 @@ export interface ToolEntry {
121
128
  * result lands and only when something renderable exists.
122
129
  */
123
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
124
294
  }
125
295
 
126
296
  /** One slash-command execution dispatched through `ctx.commands`. */
@@ -193,7 +363,7 @@ export interface FilesEntry {
193
363
  }
194
364
 
195
365
  /** Ordered transcript items the renderer draws. */
196
- 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
197
367
 
198
368
  /** The live goal the status line badges, folded from `goal/change`. */
199
369
  export interface GoalFold {
@@ -208,14 +378,21 @@ export interface GoalFold {
208
378
  blocked: string
209
379
  }
210
380
 
211
- /** 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
+ */
212
387
  export interface UsageTotals {
213
- /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
214
- inputTokens: number
388
+ /** Prompt-side tokens billed outside the cache. */
389
+ uncachedInputTokens: number
215
390
  /** Completion-side tokens over the whole log. */
216
391
  outputTokens: number
217
392
  /** Cache-read tokens over the whole log (0 when the adapter reports none). */
218
393
  cacheReadTokens: number
394
+ /** Cache-write tokens over the whole log (0 when the adapter reports none). */
395
+ cacheWriteTokens: number
219
396
  }
220
397
 
221
398
  /**
@@ -273,6 +450,86 @@ export interface TranscriptStats {
273
450
  reasoningEffort: string
274
451
  }
275
452
 
453
+ /** One active reminder folded from durable `schedule/change` events. */
454
+ export interface ScheduleRow {
455
+ readonly id: string
456
+ readonly kind: 'after' | 'at' | 'every'
457
+ readonly prompt: string
458
+ /** Next due time (epoch ms); the /schedule panel derives overdue/relative labels. */
459
+ readonly targetAt: number
460
+ /** Recurrence seconds for 'every' rows, undefined otherwise. */
461
+ readonly everySeconds?: number
462
+ }
463
+
464
+ /**
465
+ * The durable `schedule/change` payload shape this fold consumes. Upstream
466
+ * strict-decodes the whole transition stream before appending, so unknown
467
+ * ids here are corrupt-input edges that degrade to a no-op.
468
+ */
469
+ export interface ScheduleChangeLike {
470
+ readonly operation: 'create' | 'delete' | 'dispatch'
471
+ readonly schedule?: {
472
+ readonly id: string
473
+ readonly kind: 'after' | 'at' | 'every'
474
+ readonly prompt: string
475
+ readonly afterSeconds?: number
476
+ readonly everySeconds?: number
477
+ readonly scheduledAt: string
478
+ }
479
+ readonly id?: string
480
+ readonly acceptedAt?: string
481
+ }
482
+
483
+ /*
484
+ * Upstream record semantics (dsh-schedule types): `scheduledAt` is ALREADY
485
+ * the due instant — AfterScheduleRecord carries the RFC 3339 UTC target
486
+ * (delay included), EveryScheduleRecord carries the earliest anchor-aligned
487
+ * occurrence not yet dispatched. No kind ever adds its own interval on top.
488
+ */
489
+
490
+ /** Fold one `schedule/change` into the active-reminder list (create/delete/dispatch). */
491
+ export function applyScheduleChange(rows: readonly ScheduleRow[], data: ScheduleChangeLike): readonly ScheduleRow[] {
492
+ if (data.operation === 'create' && data.schedule !== undefined) {
493
+ const schedule = data.schedule
494
+ const row: ScheduleRow = {
495
+ id: schedule.id,
496
+ kind: schedule.kind,
497
+ prompt: schedule.prompt,
498
+ targetAt: Date.parse(schedule.scheduledAt),
499
+ ...(schedule.kind === 'every' ? { everySeconds: schedule.everySeconds ?? 0 } : {}),
500
+ }
501
+ return [...rows.filter(existing => existing.id !== schedule.id), row]
502
+ }
503
+ if (data.operation === 'delete' && data.id !== undefined) {
504
+ return rows.filter(existing => existing.id !== data.id)
505
+ }
506
+ if (data.operation === 'dispatch' && data.id !== undefined) {
507
+ // A dispatched one-shot reminder is finished. An 'every' reminder
508
+ // advances PAST every missed occurrence in one step: the next target is
509
+ // the first anchor-aligned instant strictly after acceptedAt, stepping
510
+ // from the previous aligned target (upstream advances the same way).
511
+ if (data.acceptedAt === undefined) return rows.filter(existing => existing.id !== data.id)
512
+ const accepted = Date.parse(data.acceptedAt)
513
+ return rows.map(existing => existing.id === data.id
514
+ ? { ...existing, targetAt: nextEveryTarget(existing.targetAt, accepted, existing.everySeconds ?? 0) }
515
+ : existing)
516
+ }
517
+ return rows
518
+ }
519
+
520
+ /** First anchor-aligned target after `acceptedAt`, stepping from the previous aligned target. */
521
+ export function nextEveryTarget(previousTarget: number, acceptedAt: number, everySeconds: number): number {
522
+ const interval = Math.max(1, everySeconds) * 1000
523
+ if (acceptedAt <= previousTarget) return previousTarget + interval
524
+ const missed = Math.ceil((acceptedAt - previousTarget + 1) / interval)
525
+ return previousTarget + missed * interval
526
+ }
527
+
528
+ /** Plugin snapshot sources folded into token stats but never rendered as rows. */
529
+ const HIDDEN_SNAPSHOT_PLUGINS = new Set(['time-context', 'tmux-context'])
530
+ /** Plugin prompt sources rendered as full user rows (they ARE the conversation). */
531
+ const REMINDER_PLUGINS = new Set(['schedule'])
532
+
276
533
  /** The complete TUI transcript view for one session. */
277
534
  export interface TranscriptView {
278
535
  /** Settled entries in log order. */
@@ -319,12 +576,22 @@ export interface TranscriptView {
319
576
  sandbox: string
320
577
  /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
321
578
  goal: GoalFold | undefined
579
+ /** Active reminders folded from `schedule/change` events, oldest target first at render. */
580
+ schedules: readonly ScheduleRow[]
322
581
  /**
323
582
  * Ordered live message ids per inbox target, mirrored from
324
583
  * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
325
584
  * coordinates later removals resolve against.
326
585
  */
327
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'>
328
595
  /**
329
596
  * Fold-internal timing anchors, never rendered: open step and tool-call
330
597
  * start timestamps the next `assistant/message` / `tool/result` resolves
@@ -336,6 +603,8 @@ export interface TranscriptView {
336
603
  readonly anchors: {
337
604
  stepStart: Map<string, number>
338
605
  toolStart: Map<string, number>
606
+ /** Open PTC sub-dispatch starts by `subCallId` (duration anchors). */
607
+ subStart: Map<string, number>
339
608
  firstChunkAt: Map<string, number>
340
609
  compactionTokens: Map<string, number>
341
610
  lastPruneTokens: number
@@ -399,6 +668,7 @@ function cloneViewAnchors(anchors: TranscriptView['anchors']): TranscriptView['a
399
668
  return {
400
669
  stepStart: new Map(anchors.stepStart),
401
670
  toolStart: new Map(anchors.toolStart),
671
+ subStart: new Map(anchors.subStart),
402
672
  firstChunkAt: new Map(anchors.firstChunkAt),
403
673
  compactionTokens: new Map(anchors.compactionTokens),
404
674
  lastPruneTokens: anchors.lastPruneTokens,
@@ -491,9 +761,11 @@ export function createTranscriptView(): TranscriptView {
491
761
  systemPrompt: '',
492
762
  sandbox: '',
493
763
  goal: undefined,
764
+ schedules: [],
494
765
  pending: { 'next-turn': [], 'next-step': [] },
495
- 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: '' },
496
- 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() },
497
769
  }
498
770
  }
499
771
 
@@ -538,6 +810,18 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
538
810
  pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
539
811
  entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
540
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 }
541
825
  // Injected context (plugin/model-continuation sources) stays collapsed
542
826
  // to a bounded notice row, exactly like collapsed transcript context
543
827
  // elsewhere in the product; only direct human prompts render in full.
@@ -548,7 +832,41 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
548
832
  return {
549
833
  ...view,
550
834
  pending,
551
- 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 }) }],
837
+ stats: {
838
+ ...view.stats,
839
+ contextSegments: {
840
+ ...view.stats.contextSegments,
841
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
842
+ },
843
+ },
844
+ }
845
+ }
846
+ // Snapshot injections (time/tmux context) still spend model context but
847
+ // render nothing; the schedule reminder is a real prompt and renders in
848
+ // full — the model acts on it, so the transcript must show it.
849
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
850
+ return {
851
+ ...view,
852
+ pending,
853
+ claimOrigin,
854
+ entries,
855
+ stats: {
856
+ ...view.stats,
857
+ contextSegments: {
858
+ ...view.stats.contextSegments,
859
+ system: view.stats.contextSegments.system + estimateTokens(text),
860
+ },
861
+ },
862
+ }
863
+ }
864
+ if (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin)) {
865
+ return {
866
+ ...view,
867
+ pending,
868
+ claimOrigin,
869
+ entries: [...entries, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) }],
552
870
  stats: {
553
871
  ...view.stats,
554
872
  contextSegments: {
@@ -560,11 +878,14 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
560
878
  }
561
879
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
562
880
  ? message.source.summary
563
- : message.source.kind
881
+ : message.source.kind === 'plugin'
882
+ ? message.source.plugin
883
+ : message.source.kind
564
884
  const summary = boundContextSummary(notice)
565
885
  return {
566
886
  ...view,
567
887
  pending,
888
+ claimOrigin,
568
889
  entries: [...entries, { kind: 'user', text: summary, notice: true }],
569
890
  stats: {
570
891
  ...view.stats,
@@ -579,9 +900,29 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
579
900
  // The durable inbox mutation (web queue-mirror contract, event-sourced):
580
901
  // removals drop the projected rows at their inbox coordinates, inserted
581
902
  // messages gain a pending row at their log position.
582
- const { target, start, removedCount = 0, inserted } = event.data
903
+ const { target, start, removedCount = 0, inserted, outcome } = event.data
583
904
  const ids = view.pending[target]
584
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
+ }
585
926
  // In-place upstream semantics: the kernel's authoritative fold is
586
927
  // `inbox.splice(start, removedCount, ...inserted)` — inserted ids land
587
928
  // AT the splice position (prepend/replace shapes), never at the tail.
@@ -600,6 +941,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
600
941
  !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
601
942
  }
602
943
  for (const message of inserted) {
944
+ if (inFlight) remember(message.id)
603
945
  entries = [...entries, {
604
946
  kind: 'pending',
605
947
  messageId: message.id,
@@ -609,7 +951,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
609
951
  ...filesOf(message.content).length === 0 ? {} : { files: filesOf(message.content) },
610
952
  }]
611
953
  }
612
- return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
954
+ return { ...view, entries, claimOrigin, pending: { ...view.pending, [target]: nextIds } }
613
955
  }
614
956
  case 'system/message': {
615
957
  // Session-log v3 carries the system prompt as surface nodes (the
@@ -693,18 +1035,25 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
693
1035
  const totals = stats.usage
694
1036
  const text = textOf(event.data.message.content)
695
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
696
1044
  return {
697
1045
  ...view,
698
1046
  streaming: '',
699
1047
  streamingReasoning: '',
700
- 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 }] } : {}),
701
1049
  stats: {
702
1050
  ...stats,
703
1051
  llmMs: stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
704
1052
  usage: usage === undefined ? totals : {
705
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1053
+ uncachedInputTokens: totals.uncachedInputTokens + usage.inputTokens,
706
1054
  outputTokens: totals.outputTokens + usage.outputTokens,
707
1055
  cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1056
+ cacheWriteTokens: totals.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
708
1057
  },
709
1058
  lastPromptTokens: usage === undefined ? stats.lastPromptTokens
710
1059
  : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
@@ -745,6 +1094,8 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
745
1094
  state: 'running',
746
1095
  summary: '',
747
1096
  detail: undefined,
1097
+ subs: [],
1098
+ subsDropped: 0,
748
1099
  }],
749
1100
  stats: {
750
1101
  ...view.stats,
@@ -963,6 +1314,11 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
963
1314
  entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
964
1315
  }
965
1316
  }
1317
+ case 'schedule/change':
1318
+ // Non-conversational catalog state: the /schedule panel renders the
1319
+ // active list, the transcript shows only the reminder prompts
1320
+ // (handled at user/message above).
1321
+ return { ...view, schedules: applyScheduleChange(view.schedules, event.data) }
966
1322
  case 'session/title':
967
1323
  // Latest-wins title snapshot, log-only; the status line prefers it.
968
1324
  return { ...view, title: event.data.title }
@@ -1043,8 +1399,86 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
1043
1399
  })
1044
1400
  return { ...view, entries }
1045
1401
  }
1046
- 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
+ }
1047
1480
  return view
1481
+ }
1048
1482
  }
1049
1483
  }
1050
1484
 
@@ -1079,11 +1513,15 @@ export interface ReplayAccumulator {
1079
1513
  retryIndex: Map<string, number[]>
1080
1514
  /** messageId → every index into `entries` holding a `pending` row with that id. */
1081
1515
  pendingIndex: Map<string, number[]>
1516
+ /** runId → every index into `entries` holding a `workflow` row with that id. */
1517
+ workflowIndex: Map<string, number[]>
1082
1518
  /** Tombstone count; zero means `entries` is already the final array. */
1083
1519
  removedCount: number
1084
1520
  /** Mutable inbox id lists, mirroring `view.pending` order per target. */
1085
1521
  pendingTurn: string[]
1086
1522
  pendingStep: string[]
1523
+ /** Mutable mirror of `view.claimOrigin` (see the reducer's field doc). */
1524
+ claimOrigin: Map<string, 'next-turn' | 'next-step'>
1087
1525
  streaming: string
1088
1526
  streamingReasoning: string
1089
1527
  todos: readonly TodoItem[]
@@ -1098,9 +1536,12 @@ export interface ReplayAccumulator {
1098
1536
  systemPrompt: string
1099
1537
  sandbox: string
1100
1538
  goal: GoalFold | undefined
1539
+ schedules: readonly ScheduleRow[]
1101
1540
  stats: TranscriptStats
1102
1541
  stepStart: Map<string, number>
1103
1542
  toolStart: Map<string, number>
1543
+ /** Open PTC sub-dispatch starts by `subCallId` (duration anchors). */
1544
+ subStart: Map<string, number>
1104
1545
  firstChunkAt: Map<string, number>
1105
1546
  compactionTokens: Map<string, number>
1106
1547
  lastPruneTokens: number
@@ -1121,9 +1562,11 @@ export function createReplayAccumulator(): ReplayAccumulator {
1121
1562
  commandIndex: new Map(),
1122
1563
  retryIndex: new Map(),
1123
1564
  pendingIndex: new Map(),
1565
+ workflowIndex: new Map(),
1124
1566
  removedCount: 0,
1125
1567
  pendingTurn: [],
1126
1568
  pendingStep: [],
1569
+ claimOrigin: new Map(),
1127
1570
  streaming: '',
1128
1571
  streamingReasoning: '',
1129
1572
  todos: [],
@@ -1137,9 +1580,11 @@ export function createReplayAccumulator(): ReplayAccumulator {
1137
1580
  systemPrompt: '',
1138
1581
  sandbox: '',
1139
1582
  goal: undefined,
1140
- 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: '' },
1583
+ schedules: [],
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: '' },
1141
1585
  stepStart: new Map(),
1142
1586
  toolStart: new Map(),
1587
+ subStart: new Map(),
1143
1588
  firstChunkAt: new Map(),
1144
1589
  compactionTokens: new Map(),
1145
1590
  lastPruneTokens: 0,
@@ -1270,8 +1715,14 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1270
1715
  const text = textOf(message.content)
1271
1716
  const images = imagesOf(message.content)
1272
1717
  const files = filesOf(message.content)
1273
- if (message.source.kind === 'user') {
1274
- appendReplayEntry(acc, { kind: 'user', text, notice: false, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
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)
1724
+ if (message.source.kind === 'user' || (message.source.kind === 'plugin' && REMINDER_PLUGINS.has(message.source.plugin))) {
1725
+ appendReplayEntry(acc, { kind: 'user', text, notice: false, ...delivery, ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
1275
1726
  acc.stats = {
1276
1727
  ...acc.stats,
1277
1728
  contextSegments: {
@@ -1281,9 +1732,21 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1281
1732
  }
1282
1733
  return true
1283
1734
  }
1735
+ if (message.source.kind === 'plugin' && HIDDEN_SNAPSHOT_PLUGINS.has(message.source.plugin)) {
1736
+ acc.stats = {
1737
+ ...acc.stats,
1738
+ contextSegments: {
1739
+ ...acc.stats.contextSegments,
1740
+ system: acc.stats.contextSegments.system + estimateTokens(text),
1741
+ },
1742
+ }
1743
+ return true
1744
+ }
1284
1745
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
1285
1746
  ? message.source.summary
1286
- : message.source.kind
1747
+ : message.source.kind === 'plugin'
1748
+ ? message.source.plugin
1749
+ : message.source.kind
1287
1750
  const summary = boundContextSummary(notice)
1288
1751
  appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
1289
1752
  acc.stats = {
@@ -1296,12 +1759,15 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1296
1759
  return true
1297
1760
  }
1298
1761
  case 'agent/inbox/spliced': {
1299
- const { target, start, removedCount = 0, inserted } = event.data
1762
+ const { target, start, removedCount = 0, inserted, outcome } = event.data
1300
1763
  const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1301
1764
  const removed = ids.slice(start, start + removedCount)
1302
1765
  acc.ops += removed.length
1303
1766
  ids.splice(start, removedCount)
1304
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)
1305
1771
  for (const id of removed) {
1306
1772
  const list = acc.pendingIndex.get(id)
1307
1773
  if (list === undefined) continue
@@ -1318,6 +1784,9 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1318
1784
  // later inbox event.
1319
1785
  ids.splice(start, 0, ...inserted.map(message => message.id))
1320
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)
1321
1790
  const images = imagesOf(message.content)
1322
1791
  const files = filesOf(message.content)
1323
1792
  appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content), ...(images.length === 0 ? {} : { images }), ...(files.length === 0 ? {} : { files }) })
@@ -1394,14 +1863,19 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1394
1863
  const reasoning = reasoningOf(event.data.message.content)
1395
1864
  acc.streaming = ''
1396
1865
  acc.streamingReasoning = ''
1397
- 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
+ }
1398
1871
  acc.stats = {
1399
1872
  ...acc.stats,
1400
1873
  llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1401
1874
  usage: usage === undefined ? totals : {
1402
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1875
+ uncachedInputTokens: totals.uncachedInputTokens + usage.inputTokens,
1403
1876
  outputTokens: totals.outputTokens + usage.outputTokens,
1404
1877
  cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1878
+ cacheWriteTokens: totals.cacheWriteTokens + (usage.cacheWriteTokens ?? 0),
1405
1879
  },
1406
1880
  lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1407
1881
  : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
@@ -1433,6 +1907,8 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1433
1907
  state: 'running',
1434
1908
  summary: '',
1435
1909
  detail: undefined,
1910
+ subs: [],
1911
+ subsDropped: 0,
1436
1912
  })
1437
1913
  indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1438
1914
  acc.stats = {
@@ -1603,6 +2079,10 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1603
2079
  if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1604
2080
  return true
1605
2081
  }
2082
+ case 'schedule/change':
2083
+ acc.schedules = applyScheduleChange(acc.schedules, event.data)
2084
+ acc.ops += 1
2085
+ return true
1606
2086
  case 'session/title':
1607
2087
  acc.title = event.data.title
1608
2088
  return true
@@ -1664,8 +2144,75 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
1664
2144
  updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1665
2145
  return true
1666
2146
  }
1667
- 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
+ }
1668
2214
  return false
2215
+ }
1669
2216
  }
1670
2217
  }
1671
2218
 
@@ -1715,13 +2262,16 @@ function materializeReplayView(acc: ReplayAccumulator, copy: boolean): Transcrip
1715
2262
  systemPrompt: acc.systemPrompt,
1716
2263
  sandbox: acc.sandbox,
1717
2264
  goal: acc.goal,
2265
+ schedules: acc.schedules,
1718
2266
  pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
2267
+ claimOrigin: new Map(acc.claimOrigin),
1719
2268
  stats: acc.stats,
1720
2269
  // Handed-out views get their own anchors snapshot: the accumulator keeps
1721
2270
  // folding its live containers, and no consumer may observe that.
1722
2271
  anchors: {
1723
2272
  stepStart: new Map(acc.stepStart),
1724
2273
  toolStart: new Map(acc.toolStart),
2274
+ subStart: new Map(acc.subStart),
1725
2275
  firstChunkAt: new Map(acc.firstChunkAt),
1726
2276
  compactionTokens: new Map(acc.compactionTokens),
1727
2277
  lastPruneTokens: acc.lastPruneTokens,
@@ -1828,6 +2378,7 @@ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1828
2378
  if (entry.kind === 'tool' && entry.state === 'running') return index
1829
2379
  if (entry.kind === 'retry' && entry.state === 'running') return index
1830
2380
  if (entry.kind === 'command' && entry.state === 'running') return index
2381
+ if (entry.kind === 'workflow' && entry.state === 'running') return index
1831
2382
  }
1832
2383
  return entries.length
1833
2384
  }