dsh-workflow-map 0.0.0-stage → 0.3.2

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.
package/src/fold.mjs ADDED
@@ -0,0 +1,2633 @@
1
+ // dsh-workflow-map — src/fold.mjs . PURE FUNCTION. No clock, no fs, no net.
2
+ //
3
+ // The single source of truth for the workflow view: "step list + each step's flow nodes +
4
+ // currently running step/node + per-step percentage". Three consumers, one implementation:
5
+ // 1. node (offline self-test): import foldEvent and buildView out of src/fold.mjs
6
+ // 2. the host projection : the same relative import from lib/index.js
7
+ // 3. (later) the browser half: an inlined copy, kept honest by a differential test
8
+ //
9
+ // CONTRACT FACTS ARE MEASURED, NOT GUESSED (2026-10-17, this session's own log,
10
+ // %USERPROFILE%\.dsh\sessions\…\session-f069ece7…\session.v4.jsonl.zstd):
11
+ // * envelope = { type, seq, time, data, surfaceOp?, sourceEventSeqs? }; seq is strictly
12
+ // increasing; `time` is wall-clock ms (the first event has no seq at all).
13
+ // * todo/write data.todos[] = { content, status } — REPLACES the whole table.
14
+ // * tool/call data = { turn, step, callId, name, arguments } — arguments is a JSON STRING.
15
+ // * tool/result data = { turn, step, message } — message.toolCallId, message.content[]
16
+ // (blocks of {type:"text",text}), message.isError (absent on success).
17
+ // * step/start / step/end data = { turn, step } — these are MODEL steps (a turn has many),
18
+ // NOT plan steps. They bound a step's activity, but the plan's own boundaries are PAE's.
19
+ // * assistant/message data = { turn, step, message:{content:[…]}, usage, stream } — content
20
+ // blocks carry type reasoning|text|tool-call. `stream` is a chunk array: NEVER fold it.
21
+ // * user/message data = { content:[{type:"text",text}], source:{kind}, role, id }. The FIRST
22
+ // block of the first event whose source.kind === "user" is the task text.
23
+ // * stepProgress/report data = { percent, note? } — the model self-report (our own event
24
+ // type, appended by the dsh-session-progress tool `report_step_progress`).
25
+ // * PAE writes `user/message` events with source.kind === "plugin:dsh-plan-and-execute":
26
+ // the kickoff/summary/step instructions AND the "[plan-and-execute previous result: ...]"
27
+ // reports. `orchestrator.json` (read by NOT here) carries anchorSeqs
28
+ // { stepIndex: seq } — the seq of the context message that opens each plan step.
29
+ // * Unrelated types seen in a live log: job/*, llm/*, compaction/*, request/*,
30
+ // command/run, command/done, workspace/changes, assistant/attempt, system/message,
31
+ // session-log-deepseek/*. They return the SAME state reference.
32
+ //
33
+ // BRANCH VOCABULARY — read out of the harness's own SessionEventMap (the type
34
+ // list and the per-type payload validators inside app.asar), NOT guessed:
35
+ // * the session log carries `job/registered` { jobId, owner, kind, label, startedAt,
36
+ // toolCall? } and `job/settled` { …the same fields, outcome: { status:
37
+ // 'completed' | 'killed' | 'failed', detail?, result? } } — a background long task.
38
+ // * a workflow tool run reaches the log as four log-only events: `tool-workflow/run-start`
39
+ // { runId, name }, `tool-workflow/agent-start` { runId, seq, label, childId, phase? },
40
+ // `tool-workflow/agent-end` { runId, seq, outcome }, `tool-workflow/run-end`
41
+ // { runId, stopReason: 'completed' | 'cancelled' | 'error' }.
42
+ // * `subagent/start` / `subagent/end` and `workflow/*` are PLUGIN-BUS events: they are NOT
43
+ // members of the session-log type list, so a recorded log never contains them. A
44
+ // subagent therefore reaches this fold as the `tool/call` that launched it (`subagent`
45
+ // / `subagent_fork`) plus the matching `tool/result`: the branch opens at the call and
46
+ // closes at the result. The bus payload shapes are still handled, so a host that does
47
+ // record them produces the same lanes (one branch per run, never two).
48
+ // SIZING (step file §7): a real turn carries 100+ tool calls; the state is persisted to the
49
+ // session cache, so nothing large may enter it. Text is truncated at the boundary
50
+ // (NODE_TEXT=200, SUMMARY=120), per-step nodes are capped at 300 with head/tail kept, and
51
+ // the node count in the wire view is a total cap across the whole session.
52
+ //
53
+ // IDEMPOTENCE: folding the same event twice must not change the shape. That is why every
54
+ // append is guarded by the event's `seq` (the first event of a log has no seq — it is a
55
+ // `session` header, which this fold ignores anyway).
56
+
57
+ // STATE_VERSION 3 : the state carries the INBOX LEDGER (`inboxIds`), because
58
+ // the durable record of a revocation is positional — `agent/inbox/spliced { target, start,
59
+ // removedCount, inserted, outcome: 'canceled' }` names an INDEX, never the message that left
60
+ // (measured 2026-10-26 on the host's own `ReactLoopInbox.mutate`, and on 145 real session logs:
61
+ // 11 lines carry `outcome:"canceled"`, 0 carry a durable `agent/inbox/discarded`). To say WHICH
62
+ // of our inject rows the loop dropped, the fold has to replay the same splice sequence the loop's
63
+ // own `inboxProjectionDefinition` replays. A checkpoint written before this bump has no ledger,
64
+ // so the registry is told to drop it and rebuild from the log (the same contract as v1→v2: a
65
+ // missing field must never be silently answered with a wrong index).
66
+ export const STATE_VERSION = 3;
67
+
68
+ export const SCHEMA_VERSION = 1;
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // limits — every text that enters the state is bounded here, nowhere else
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** Longest text kept anywhere in the state (node detail, result head, summary). */
75
+ export const TEXT_LIMIT = 200;
76
+
77
+ /** Longest step title / task title. */
78
+ export const TITLE_LIMIT = 120;
79
+
80
+ /** Longest command text kept for a `pwsh` node. */
81
+ export const COMMAND_LIMIT = 200;
82
+
83
+ /** Longest argsText for any node. */
84
+ export const ARGS_LIMIT = 200;
85
+
86
+ /** Nodes kept per step (head + tail, middle dropped with `truncatedNodes` recorded). */
87
+ export const NODES_PER_STEP = 300;
88
+
89
+ /**
90
+ * Total nodes kept across the whole session. Measured on a real 4-turn session (2026-10-17):
91
+ * 566 nodes -> 286 kB of persisted state, i.e. ~0.5 kB per node, so the cap is what keeps a
92
+ * long session's cache row in the hundreds of kB instead of the megabytes.
93
+ */
94
+ export const NODES_TOTAL = 1200;
95
+
96
+ /** Consecutive same-tool nodes with the same target that get folded into a group. */
97
+ export const GROUP_THRESHOLD = 3;
98
+
99
+ /** Activity rows kept per branch lane (the oldest are dropped, `count` stays honest). */
100
+ export const BRANCH_NODES = 40;
101
+
102
+ /** Branches the view draws for one step; the rest are counted, never silently lost. */
103
+ export const BRANCHES_SHOWN = 4;
104
+
105
+ /** Branches kept in the persisted state; the oldest CLOSED one is dropped first. */
106
+ export const BRANCH_TOTAL = 12;
107
+
108
+ /** Longest per-step percent series (model reports) kept. */
109
+ export const REPORTS_PER_STEP = 40;
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // event vocabulary (the measured list from the log of 2026-10-17)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const TYPE_TODOS = "todo/write";
116
+
117
+ /** Title of the synthetic step that holds everything done before the first plan step. */
118
+ const PLANNING_TITLE = "规划与调研";
119
+ const TYPE_TOOL_CALL = "tool/call";
120
+ const TYPE_TOOL_RESULT = "tool/result";
121
+ const TYPE_STEP_START = "step/start";
122
+ const TYPE_STEP_END = "step/end";
123
+ const TYPE_ASSISTANT = "assistant/message";
124
+ const TYPE_USER = "user/message";
125
+ const TYPE_TURN_START = "turn/start";
126
+ const TYPE_TURN_END = "turn/end";
127
+ const TYPE_REPORT = "stepProgress/report";
128
+ const TYPE_SUBAGENT_START = "subagent/start";
129
+ const TYPE_SUBAGENT_END = "subagent/end";
130
+ const TYPE_WORKFLOW = "workflow";
131
+ const TYPE_WORKFLOW_START = "workflow/start";
132
+ const TYPE_WORKFLOW_PHASE = "workflow/phase";
133
+ const TYPE_WORKFLOW_END = "workflow/end";
134
+ const TYPE_TW_RUN_START = "tool-workflow/run-start";
135
+ const TYPE_TW_RUN_END = "tool-workflow/run-end";
136
+ const TYPE_TW_AGENT_START = "tool-workflow/agent-start";
137
+ const TYPE_TW_AGENT_END = "tool-workflow/agent-end";
138
+ const TYPE_JOB_REGISTERED = "job/registered";
139
+ const TYPE_JOB_SETTLED = "job/settled";
140
+ const TYPE_USER_QUESTIONS = "user-questions/request";
141
+ const TYPE_APPROVAL = "approval/request";
142
+
143
+ // The hot-inject message this plugin's own hot-inject message and the inbox splice that carries it.
144
+ // Measured in this session's log (session-f1b5bf9d, 2026-10-18): the panel's POST lands as
145
+ // `agent/inbox/spliced { target: "next-step", inserted:[{ source:{kind:"plugin:dsh-workflow-map"} }] }`
146
+ // and is admitted as a `user/message` with the same `source.kind`. Those two facts are what
147
+ // makes a hot-inject HISTORY possible without a second write path.
148
+ const TYPE_INBOX_SPLICED = "agent/inbox/spliced";
149
+ // The loop's LIVE notice that a message left an inbox: `dispatch.emit("agent/inbox/discarded",
150
+ // { message })` — it carries the message itself, but it is NOT a session event (the agent-loop
151
+ // README is explicit: "Consumers that need a removed message use the claimed or discarded
152
+ // notification instead of depending on a pre-splice session/event view"). Measured 2026-10-26 on
153
+ // all 145 readable logs: zero durable `agent/inbox/discarded` lines. The fold still answers it —
154
+ // a hand-made or future log that does carry it must flip the row — but the DURABLE cancellation
155
+ // this plugin renders from is the `agent/inbox/spliced` with `outcome: 'canceled'` below.
156
+ const TYPE_INBOX_DISCARDED = "agent/inbox/discarded";
157
+ const PLUGIN_SOURCE_PREFIX = "plugin:";
158
+ const SELF_SOURCE_KIND = "plugin:dsh-workflow-map";
159
+ const TYPE_APPROVAL_ASKED = "approval/asked";
160
+ const TYPE_APPROVAL_DECIDED = "approval/decided";
161
+ // The tool the model uses to ask the user something (measured name, from the harness's
162
+ // own tool definition in dsh-tool-ask-user).
163
+ const ASK_USER_QUESTION_TOOL = "ask_user_question";
164
+ /** Everything this fold answers to. Anything else returns the very same state reference. */
165
+ const RELATED = new Set([
166
+ TYPE_TODOS,
167
+ TYPE_TOOL_CALL,
168
+ TYPE_TOOL_RESULT,
169
+ TYPE_STEP_START,
170
+ TYPE_STEP_END,
171
+ TYPE_ASSISTANT,
172
+ TYPE_USER,
173
+ TYPE_TURN_START,
174
+ TYPE_TURN_END,
175
+ TYPE_REPORT,
176
+ TYPE_SUBAGENT_START,
177
+ TYPE_SUBAGENT_END,
178
+ TYPE_WORKFLOW,
179
+ TYPE_WORKFLOW_START,
180
+ TYPE_WORKFLOW_PHASE,
181
+ TYPE_WORKFLOW_END,
182
+ TYPE_TW_RUN_START,
183
+ TYPE_TW_RUN_END,
184
+ TYPE_TW_AGENT_START,
185
+ TYPE_TW_AGENT_END,
186
+ TYPE_JOB_REGISTERED,
187
+ TYPE_JOB_SETTLED,
188
+ TYPE_USER_QUESTIONS,
189
+ TYPE_APPROVAL,
190
+ TYPE_INBOX_SPLICED,
191
+ TYPE_INBOX_DISCARDED,
192
+ TYPE_APPROVAL_ASKED,
193
+ TYPE_APPROVAL_DECIDED,
194
+ ]);
195
+
196
+ /**
197
+ * Whether an event type can enter the fold. The declared set carries the measured names;
198
+ * the prefixes catch the rest of a family (`workflow/log`, `job/progress`, …) so that an
199
+ * unhandled member lands in `foldOne`'s default (state untouched) instead of being
200
+ * mistaken for a new session.
201
+ */
202
+ function isRelated(type) {
203
+ return (
204
+ RELATED.has(type) || type.startsWith("job/") || type.startsWith("workflow/") || type.startsWith("tool-workflow/")
205
+ );
206
+ }
207
+
208
+ /** Source kind prefix of the plan-and-execute plugin's own user messages. */
209
+ const PAE_SOURCE_PREFIX = "plugin:dsh-plan-and-execute";
210
+
211
+ /** Model-step boundary kinds, kept so the fold can bound "activity" without guessing. */
212
+ const STEP_STATUS = { pending: "pending", running: "running", done: "done", failed: "failed" };
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // small helpers
216
+ // ---------------------------------------------------------------------------
217
+
218
+ const isObject = (value) => value !== null && typeof value === "object";
219
+ const isRecord = (value) => isObject(value) && !Array.isArray(value);
220
+ const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
221
+
222
+ const clamp = (value, low, high) => Math.max(low, Math.min(value, high));
223
+
224
+ /** Bounded copy of any text. Never throws, never returns a non-string. */
225
+ export function text(value, limit = TEXT_LIMIT) {
226
+ if (typeof value !== "string") return "";
227
+ const flat = value.replace(/\s+/g, " ").trim();
228
+ if (flat.length <= limit) return flat;
229
+ return `${flat.slice(0, Math.max(0, limit - 1))}…`;
230
+ }
231
+
232
+ /** One line only, for result heads and error text. */
233
+ export function line(value, limit = TEXT_LIMIT) {
234
+ if (typeof value !== "string") return "";
235
+ const raw = value.split("\n").find((part) => part.trim() !== "");
236
+ return text(raw === undefined ? "" : raw, limit);
237
+ }
238
+
239
+ /** Wall-clock stamp of an event; 0 when unusable (never invented). */
240
+ function eventTime(event) {
241
+ const raw = isObject(event) ? event.time : undefined;
242
+ return isFiniteNumber(raw) && raw > 0 ? raw : 0;
243
+ }
244
+
245
+ /** Event seq; null when the envelope carries none (the session header). */
246
+ function eventSeq(event) {
247
+ const raw = isObject(event) ? event.seq : undefined;
248
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : null;
249
+ }
250
+
251
+ /** The `data` payload of an event, or null. */
252
+ function dataOf(event) {
253
+ return isObject(event) && isRecord(event.data) ? event.data : null;
254
+ }
255
+
256
+ /** Basename of a path, with separators normalised to "/". */
257
+ function baseName(path) {
258
+ if (typeof path !== "string" || path === "") return "";
259
+ const flat = path.replace(/\\/g, "/").replace(/\/+$/, "");
260
+ const at = flat.lastIndexOf("/");
261
+ return at === -1 ? flat : flat.slice(at + 1);
262
+ }
263
+
264
+ /** Parent directory of a path (for grouping), or "". */
265
+ function dirName(path) {
266
+ if (typeof path !== "string" || path === "") return "";
267
+ const flat = path.replace(/\\/g, "/").replace(/\/+$/, "");
268
+ const at = flat.lastIndexOf("/");
269
+ return at === -1 ? "" : flat.slice(0, at);
270
+ }
271
+
272
+ /** Parse the JSON string that `tool/call` carries. Never throws. */
273
+ function parseArgs(raw) {
274
+ if (typeof raw !== "string" || raw === "") return null;
275
+ try {
276
+ const parsed = JSON.parse(raw);
277
+ return isRecord(parsed) ? parsed : null;
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ /** First text block of a tool result's content array. */
284
+ function resultText(content) {
285
+ if (!Array.isArray(content)) return "";
286
+ for (const block of content) {
287
+ if (isRecord(block) && typeof block.text === "string" && block.text !== "") return block.text;
288
+ }
289
+ return "";
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // state
294
+ // ---------------------------------------------------------------------------
295
+ //
296
+ // {
297
+ // stateVersion: 1,
298
+ // asOf: number, // time of the last event folded in (the caller's `now`)
299
+ // task: string, // first real user message, title-cased and truncated
300
+ // todos: Item[], // the latest todo table (content/status only)
301
+ // steps: Step[], // ordered; index 0 is the first step
302
+ // nodes: Node[], // flat, ordered; `stepIndex` points into steps
303
+ // active: { stepIndex, nodeId } | null,
304
+ // reports: { stepIndex: percent }[], // model self-reports (high water per step)
305
+ // durations: number[], // finished step durations (the baseline pool)
306
+ // truncated: { stepNodes: number, totalNodes: number },
307
+ // seq: number, // last folded seq (idempotence guard)
308
+ // }
309
+
310
+ /** Fresh, empty state. The projection registry hands it to `init()`. */
311
+ export function createInitialState() {
312
+ return {
313
+ stateVersion: STATE_VERSION,
314
+ asOf: 0,
315
+ task: "",
316
+ // Session identity, captured by the host half's init(header): the PAE plan reader needs
317
+ // both to name `<cwd>/.pae/<sessionId>/orchestrator.json` while serving a wire view.
318
+ sessionId: "",
319
+ cwd: "",
320
+ todos: [],
321
+ steps: [],
322
+ nodes: [],
323
+ active: null,
324
+ reports: [],
325
+ durations: [],
326
+ truncated: { stepNodes: 0, totalNodes: 0 },
327
+ nextNodeId: 1,
328
+ // Branch lanes : parallel work (subagent / workflow / background job) that
329
+ // must NOT enter the main flow's node list. See the branch section below.
330
+ branches: [],
331
+ nextBranchId: 1,
332
+ droppedBranches: 0,
333
+ // Jobs registered but not yet settled. A job becomes a LANE only when its tool result
334
+ // arrives while it is still open (see promoteJobLane); synchronous calls never do.
335
+ pendingJobs: [],
336
+ // `{ planIndex, seq }` parked by foldPaeReport: the seq PAE itself will record as the
337
+ // anchor of the NEXT plan step (orchestrator.json anchorSeqs[planIndex]).
338
+ pendingAnchor: null,
339
+ // Is a turn open? `turn/start` → true, `turn/end` → false. This is the ONE honest
340
+ // "is the session working right now" signal the log carries: the step rows stay 'running'
341
+ // until PAE rewrites them, so they cannot answer it, and a long model answer can go minutes
342
+ // without a tool call (its events arrive at the end). Exposed as `view.running`; the browser
343
+ // half freezes every animation when it is false.
344
+ turnActive: false,
345
+ // Hot-inject history. One row per message this plugin's `/steer` channel put into
346
+ // the session: `{ id, at, text, pickedUp }`. `pickedUp` flips to true the moment the
347
+ // session's own `user/message` event with the same id enters the log — that is the
348
+ // MEASURED proof the loop admitted it (measured 2026-10-18, session-f1b5bf9d), so the
349
+ // panel can say "injected, session picked it up" instead of guessing from the HTTP 200 alone.
350
+ injects: [],
351
+ // the inbox LEDGER: the pending message ids of each loop-owned list, in
352
+ // order, replayed from the same `agent/inbox/spliced` coordinates the loop writes. It exists
353
+ // for exactly one question the durable log alone cannot answer — "the splice removed the
354
+ // message at index i; WHICH id was that?" — because a cancellation records an index, not an
355
+ // id. Mirror of the host's own `inboxProjectionDefinition` fold (and of dsh-better-sidebar's
356
+ // `pendingIdsInLog`): clamp `start` into the list, clamp the removal count, then splice.
357
+ // Bounded by the inbox itself (the loop keeps at most a couple of pending messages).
358
+ inboxIds: { "next-turn": [], "next-step": [] },
359
+ // The step indexes a decision is waiting on. Filled from the log's own shapes
360
+ // (an approval/asked with no decided yet, or a node of kind question/approval with no
361
+ // later outcome event) — never from a guess about the step's title.
362
+ pendingConfirm: [],
363
+ // The approval audit pair, folded to know which decision is still open:
364
+ // `{ id, state: "asked"|"decided", stepIndex, label, at }`. Bounded; the oldest decided
365
+ // rows are dropped first, because only an UNPAIRED ask matters.
366
+ approvals: [],
367
+ seq: null,
368
+ };
369
+ }
370
+ // ---------------------------------------------------------------------------
371
+ // steps
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /** One step row. `nodes` live in state.nodes (shared, referenced by index). */
375
+ function newStep(index, title, at, source) {
376
+ return {
377
+ index,
378
+ title: text(title, TITLE_LIMIT),
379
+ // The row's STABLE identity, derived from the same expression appendNode uses for a
380
+ // node's `stepKey`. Every row therefore has one from the moment it exists — a row created by
381
+ // `foldTodos` never goes through `syncToTodos`'s create branch, so setting it only there left
382
+ // every foldTodos-created row keyless and its nodes unattributable.
383
+ stepKey: normalizeTitle(title),
384
+ source, // "pae" | "todo"
385
+ status: STEP_STATUS.running,
386
+ startedAt: at,
387
+ endedAt: null,
388
+ percent: null,
389
+ lastEstimateAt: 0,
390
+ percentSource: "none",
391
+ maxPercent: 0,
392
+ reportSummary: "",
393
+ nodeIds: [],
394
+ nodeCount: 0,
395
+ truncatedNodes: 0,
396
+ // The seq this step opened at (the PAE context message). `undefined` = never anchored,
397
+ // which `stepForNode` reads as "not part of an anchored plan".
398
+ anchorSeq: undefined,
399
+ // The plan ordinal PAE prints ("executing plan step 3/14"). `undefined` for a todo-only session.
400
+ planIndex: undefined,
401
+ // The model step currently running inside this plan step (step/start, step/end).
402
+ modelStep: null,
403
+ lastActivityAt: 0,
404
+ // the event time of the LAST progress estimate this step received — a
405
+ // `stepProgress/report` or the `step/start` that began its first model step. 0 means
406
+ // "this step never carried a number", which the rail renders as no `ago` segment at all
407
+ // (a 0 there would be the fake number the plugin forbids). Pure data: the fold never
408
+ // reads a clock, it only stores the stamp the event itself carried.
409
+ // "success" | "failed" | "" — what PAE's own step report said (foldPaeReport).
410
+ outcome: "",
411
+ groups: [],
412
+ };
413
+ }
414
+
415
+ /**
416
+ * The step the session is working on: the FIRST running row (the todo table's own rule —
417
+ * the first in_progress entry is "current"); parallel work keeps list order.
418
+ *
419
+ * WHY THIS IS THE ONLY CANDIDATE. Measured on 129 real logs: 22 sessions ended the
420
+ * fold with 2+ rows in `running`. Cause: the synthetic "planning & research" row opens during the
421
+ * planning phase and only `openPlanStep` ever closed it, so a todo-only session (or one whose
422
+ * plan never opens a PAE step) keeps it running forever, and every later row the todo table
423
+ * re-opens adds a second one. Two running rows = two flowing rings and two animated rails in
424
+ * the panel, which is exactly what the user reported. `soleRunning()` closes the strays; the
425
+ * per-event pick below stays "first running row" so a turn's activity pointer never jumps.
426
+ */
427
+ function runningStep(state) {
428
+ for (let i = 0; i < state.steps.length; i += 1) {
429
+ if (state.steps[i].status === STEP_STATUS.running) return state.steps[i];
430
+ }
431
+ return null;
432
+ }
433
+
434
+ /**
435
+ * Keep exactly ONE running row, and keep the RIGHT one.
436
+ *
437
+ * Priority mirrors the two facts the panel draws from:
438
+ * 1. the `in_progress` todo row — the model's own "this is what I am doing now";
439
+ * 2. else the LAST running row — the most recently opened step;
440
+ * 3. else nothing to do.
441
+ *
442
+ * Every other `running` row is CLOSED at `time` (status done, percent pinned 100) instead of
443
+ * being re-labelled 'pending': the work it recorded really happened, and demoting it would
444
+ * also un-count its nodes from the completed total. A row that was never started (startedAt 0)
445
+ * cannot be closed honestly, so it alone falls back to pending.
446
+ */
447
+ function soleRunning(state, time) {
448
+ let keep = -1;
449
+ const inProgress = inProgressStepIndex(state);
450
+ if (isFiniteNumber(inProgress) && state.steps[inProgress]) keep = inProgress;
451
+ if (keep === -1) {
452
+ for (let i = state.steps.length - 1; i >= 0; i -= 1) {
453
+ if (state.steps[i].status === STEP_STATUS.running) {
454
+ keep = i;
455
+ break;
456
+ }
457
+ }
458
+ }
459
+ if (keep === -1) return state;
460
+ let durations = state.durations;
461
+ let changed = false;
462
+ const steps = state.steps.map((step, index) => {
463
+ if (index === keep || step.status !== STEP_STATUS.running) return step;
464
+ changed = true;
465
+ if (!(step.startedAt > 0)) {
466
+ return { ...step, status: STEP_STATUS.pending, endedAt: null, percent: null, percentSource: "none" };
467
+ }
468
+ durations = [...durations, time - step.startedAt].filter((value) => value > 0);
469
+ return closeStep(step, time);
470
+ });
471
+ return changed ? { ...state, steps: reindex(steps), durations } : state;
472
+ }
473
+ /**
474
+ * The plan step a node belongs to. Three sources, in priority order:
475
+ * 1. the PAE anchor range — the last anchored step whose anchorSeq <= this event's seq
476
+ * (orchestrator.json's `anchorSeqs` maps step index -> the seq of the context message
477
+ * that OPENS that step; feeds it in, and until then the PAE `user/message`
478
+ * events open the steps themselves);
479
+ * 2. the in_progress todo row, when the caller knows one;
480
+ * 3. the running step, else the last step.
481
+ *
482
+ * A node whose seq predates every anchor (planning-phase work) belongs to no step at all
483
+ * and is dropped by the caller — inventing a step for it would be a guess.
484
+ */
485
+ function stepForNode(state, seq, inProgressIndex) {
486
+ if (seq !== null && state.steps.length > 0) {
487
+ let anchored = null;
488
+ for (const step of state.steps) {
489
+ if (step.anchorSeq !== undefined && step.anchorSeq <= seq) anchored = step;
490
+ }
491
+ if (anchored) return anchored;
492
+ if (state.steps.some((step) => step.anchorSeq !== undefined)) return null;
493
+ }
494
+ if (isFiniteNumber(inProgressIndex) && state.steps[inProgressIndex]) return state.steps[inProgressIndex];
495
+ return runningStep(state);
496
+ }
497
+
498
+ /**
499
+ * Per-step percent. Four routes, in priority order:
500
+ * 1. a CLOSED step is pinned — its number is finished work, not a time estimate, so a
501
+ * later (slower) sibling must not be able to pull its bar back down;
502
+ * 2. a model self-report, BLENDED with the todo table's own completion ratio : the todo
503
+ * table is a measurement, so it decides the number whenever it has rows to speak for — an
504
+ * inflated report can no longer pin the bar above the work actually done, and a stale low one
505
+ * can no longer hide it. With no rows to measure, the report (high water) stands alone;
506
+ * 3. the todo table's completion ratio on its own (a real measurement, capped at 99 % — the
507
+ * remaining row is still in progress);
508
+ * 4. the time baseline: this step's elapsed time / the median finished-step duration, capped at
509
+ * 90 % because it is an extrapolation, not a measurement. With no finished step (the cold
510
+ * start) the plan's own ordinal - current step / total steps - stands in as `baseline:steps`, but
511
+ * only from the second step of a ≥ 2-step plan: 0 of N is not progress.
512
+ */
513
+ function percentFor(state, step, now) {
514
+ if (step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed) {
515
+ const pinned = step.percent === null ? 100 : step.percent;
516
+ return { percent: clamp(pinned, 0, 100), source: step.percentSource === "none" ? "closed" : step.percentSource };
517
+ }
518
+ // Priority 2 when a self-report exists: the todo table's completion ratio is a measurement (no
519
+ // clock, no extrapolation), so it determines the number whenever it has rows to report on.
520
+ // The self-report can only lower the result, never raise it above the measured ratio.
521
+ const rowsTotal = state.todos.length;
522
+ const rowsDone = state.todos.filter((item) => item.status === "completed").length;
523
+ const reported = state.reports.find((entry) => entry.stepIndex === step.index);
524
+ if (reported && isFiniteNumber(reported.percent)) {
525
+ // A self-report and the todo table disagree in both directions. The todo table's ratio is a
526
+ // MEASUREMENT (a count over rows; no clock, no extrapolation), so it decides the displayed number
527
+ // whenever it has rows to speak for:
528
+ // • report below it → the measurement (a stale low report must not hide work already done);
529
+ // • report above it → the measurement (a report must not claim more than the rows show).
530
+ // Keeping the report out of the displayed value removes both directions of error; it is not a
531
+ // claim about how often a report is wrong. The high-water record (step.maxPercent) is still kept
532
+ // and never rolled back; it simply no longer takes part in the displayed value.
533
+ const measured = rowsTotal > 0 && rowsDone > 0 ? Math.round((rowsDone / rowsTotal) * 100) : null;
534
+ if (measured !== null) {
535
+ // The todo table is the measurement; the self-report cannot raise the number above it.
536
+ const value = measured;
537
+ return { percent: clamp(value, 0, 99), source: "todos" };
538
+ }
539
+ return { percent: clamp(Math.max(reported.percent, step.maxPercent), 0, 100), source: "model" };
540
+ }
541
+ // The checklist's own completion ratio — a real measurement, so it sits above the time baseline.
542
+ // It never claims 100 % for an OPEN step: the remaining row is still in progress.
543
+ if (rowsTotal > 0 && rowsDone > 0) {
544
+ const real = Math.round((rowsDone / rowsTotal) * 100);
545
+ return { percent: clamp(Math.max(real, step.maxPercent), 0, 99), source: "todos" };
546
+ }
547
+ const baseline = medianOf(state.durations);
548
+ if (baseline !== null && baseline > 0 && step.startedAt > 0) {
549
+ const elapsed = Math.max(0, now - step.startedAt);
550
+ const value = Math.round((elapsed / baseline) * 100);
551
+ return { percent: clamp(Math.max(value, step.maxPercent), 0, 90), source: "baseline" };
552
+ }
553
+ // Time-baseline cold start. With no finished step there is no median to divide by, so the whole
554
+ // first stretch of a session used to show no number at all. When the plan itself already names
555
+ // ≥ 2 steps and this one is not the first, the ordinal PAE prints ("executing plan step 2/6") is a REAL
556
+ // fact about scope, and current step / total steps is worth a coarse bar. Below two steps there is no ratio
557
+ // to speak of, and the old rule stands: no number beats a fabricated one.
558
+ if (baseline === null && step.startedAt > 0) {
559
+ const total = state.steps.length;
560
+ const order = isFiniteNumber(step.planIndex) && step.planIndex > 0 ? step.planIndex : step.index + 1;
561
+ if (total >= 2 && order > 1) {
562
+ const value = Math.round((order / total) * 100);
563
+ return { percent: clamp(Math.max(value, step.maxPercent), 0, 90), source: "baseline:steps" };
564
+ }
565
+ }
566
+ return { percent: null, source: "none" };
567
+ }
568
+
569
+ function medianOf(values) {
570
+ const usable = values.filter((value) => isFiniteNumber(value) && value > 0);
571
+ if (usable.length === 0) return null;
572
+ usable.sort((a, b) => a - b);
573
+ return usable[Math.floor((usable.length - 1) / 2)];
574
+ }
575
+
576
+ /** Recompute every step's percent at `now`. Pure: same inputs, same output. */
577
+ function withPercents(state, now) {
578
+ let changed = false;
579
+ const steps = state.steps.map((step) => {
580
+ const result = percentFor(state, step, now);
581
+ const maxPercent = result.percent === null ? step.maxPercent : Math.max(step.maxPercent, result.percent);
582
+ if (maxPercent === step.maxPercent && result.percent === step.percent && result.source === step.percentSource) {
583
+ return step;
584
+ }
585
+ changed = true;
586
+ return { ...step, percent: result.percent, percentSource: result.source, maxPercent };
587
+ });
588
+ return changed ? { ...state, steps } : state;
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // nodes
593
+ // ---------------------------------------------------------------------------
594
+
595
+ /**
596
+ * One flow node. `kind` is what the UI draws:
597
+ * "tool" | "message" | "step" | "subagent" | "question" | "approval" | "report"
598
+ */
599
+ function newNode(kind, tool, title, at, extra) {
600
+ return {
601
+ id: "", // assigned by appendNode
602
+ seq: null,
603
+ kind,
604
+ tool,
605
+ title: text(title, TITLE_LIMIT),
606
+ detail: "",
607
+ files: [],
608
+ argsText: "",
609
+ resultHead: "",
610
+ error: "",
611
+ at,
612
+ durationMs: null,
613
+ // filled by appendNode when the caller did not pass one (see toolKey).
614
+ key: "",
615
+ ...extra,
616
+ };
617
+ }
618
+
619
+ /** Files a tool touched, with the action label the UI shows. */
620
+ function filesFor(name, args) {
621
+ if (!args) return [];
622
+ const out = [];
623
+ const push = (path, action) => {
624
+ if (typeof path === "string" && path !== "") out.push({ path, action });
625
+ };
626
+ switch (name) {
627
+ case "read":
628
+ push(args.path, "读");
629
+ break;
630
+ case "write":
631
+ push(args.file_path, "写");
632
+ break;
633
+ case "edit":
634
+ push(args.file_path, "改");
635
+ break;
636
+ case "glob":
637
+ case "grep":
638
+ push(args.path, "查");
639
+ break;
640
+ default:
641
+ break;
642
+ }
643
+ return out;
644
+ }
645
+
646
+ /** Command text of a `pwsh` node, with paths flagged as inferred. */
647
+ function commandText(args) {
648
+ if (!args) return "";
649
+ const command = typeof args.command === "string" ? args.command : "";
650
+ if (command === "") return "";
651
+ const paths = command.match(/[A-Za-z]:\\[^\s"';|&]+|"[^"]+"|'[^']+'/g) || [];
652
+ const unique = [...new Set(paths.map((item) => item.replace(/^["']|["']$/g, "")))].slice(0, 4);
653
+ return unique.length > 0
654
+ ? `${text(command, COMMAND_LIMIT)} [路径推断: ${unique.map((item) => baseName(item)).join(", ")}]`
655
+ : text(command, COMMAND_LIMIT);
656
+ }
657
+
658
+ /** First word of a command (the group key for consecutive `pwsh` nodes). */
659
+ function commandHead(args) {
660
+ const command = args && typeof args.command === "string" ? args.command : "";
661
+ const match = command.trim().match(/^[A-Za-z0-9_.-]+/);
662
+ return match ? match[0] : "";
663
+ }
664
+
665
+ /**
666
+ * Group key: what makes two adjacent nodes "the same kind of work" (step file §6).
667
+ * Same tool + same target (the file's directory, or the command's first word) = one group.
668
+ * Computed while the arguments are still at hand, because the node only keeps a bounded
669
+ * copy of them; re-parsing that copy would lose the key exactly when it matters.
670
+ */
671
+ function toolKey(name, args) {
672
+ if (name === "pwsh") return `pwsh:${commandHead(args)}`;
673
+ const files = filesFor(name, args);
674
+ if (files.length > 0) return `${name}:${dirName(files[0].path)}`;
675
+ return name;
676
+ }
677
+
678
+ /** Title of a tool node: tool name + the parameter that says what it was about. */
679
+ function toolTitle(name, args) {
680
+ if (!args) return name;
681
+ switch (name) {
682
+ case "read":
683
+ case "write":
684
+ case "edit":
685
+ return `${name} ${baseName(args.file_path ?? args.path ?? "") || "(无路径)"}`;
686
+ case "glob":
687
+ case "grep":
688
+ return `${name} ${baseName(args.path ?? "") || "(无路径)"}`;
689
+ case "pwsh": {
690
+ const head = commandHead(args);
691
+ return head ? `pwsh ${head}` : "pwsh";
692
+ }
693
+ case "skill":
694
+ return `skill ${typeof args.name === "string" ? args.name : ""}`.trim();
695
+ default:
696
+ break;
697
+ }
698
+ if (name === "subagent" || name === "subagent_fork" || name === "workflow") {
699
+ const description = typeof args.description === "string" ? args.description : "";
700
+ return `${name} ${text(description, 60)}`.trim();
701
+ }
702
+ return name;
703
+ }
704
+
705
+ /** Args summary kept on the node (never the full payload). */
706
+ function argsSummary(name, args) {
707
+ if (!args) return "";
708
+ const keys = Object.keys(args).filter((key) => key !== "content");
709
+ if (name === "pwsh") return commandText(args);
710
+ const parts = [];
711
+ for (const key of keys.slice(0, 4)) {
712
+ const value = args[key];
713
+ if (typeof value === "string") parts.push(`${key}=${text(value, 60)}`);
714
+ else if (isFiniteNumber(value) || typeof value === "boolean") parts.push(`${key}=${value}`);
715
+ }
716
+ return text(parts.join(" "), ARGS_LIMIT);
717
+ }
718
+
719
+ /**
720
+ * The argument DIGEST kept on a node: which parameters were given and with what shape, never
721
+ * a body. File contents (`content` / `old_str` / `new_str`) are dropped outright — a single
722
+ * 5000-character write would otherwise be the largest thing in the persisted state.
723
+ */
724
+ function argsDigest(args) {
725
+ if (!args) return "";
726
+ const out = {};
727
+ for (const key of Object.keys(args).slice(0, 12)) {
728
+ if (key === "content" || key === "old_str" || key === "new_str") continue;
729
+ const value = args[key];
730
+ if (typeof value === "string") out[key] = text(value, 60);
731
+ else if (isFiniteNumber(value) || typeof value === "boolean") out[key] = value;
732
+ else if (Array.isArray(value)) out[key] = `[${value.length}]`;
733
+ else if (isRecord(value)) out[key] = "{…}";
734
+ }
735
+ return text(JSON.stringify(out), ARGS_LIMIT);
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // fold
740
+ // ---------------------------------------------------------------------------
741
+
742
+ function foldTodos(state, event, time) {
743
+ const data = dataOf(event);
744
+ const raw = data && Array.isArray(data.todos) ? data.todos : [];
745
+ const todos = raw
746
+ .filter(isRecord)
747
+ .map((item) => ({ content: text(item.content, TITLE_LIMIT), status: typeof item.status === "string" ? item.status : "" }));
748
+
749
+ // Two jobs, in this order: (1) close the steps the table says are finished — the todo
750
+ // table is the ONE place that reports a plan step's outcome, and PAE rewrites the whole
751
+ // table after every step; (2) add any row that is not a step yet, so a plain todo-driven
752
+ // session (no PAE plan at all) still produces a step list.
753
+ let steps = [...state.steps];
754
+ let durations = state.durations;
755
+
756
+ for (const item of todos) {
757
+ if (item.content === "") continue;
758
+ const planIndex = planIndexOf(item.content);
759
+ let at = planIndex === null ? -1 : steps.findIndex((step) => step.planIndex === planIndex);
760
+ if (at === -1) at = steps.findIndex((step) => normalizeTitle(step.title) === normalizeTitle(item.content));
761
+ // REWORDED ROW at the source. The model often rewrites its own todo text between two
762
+ // `todo/write` events (measured: "read-only inventory: read plugin sources..." -> "read-only inventory: sources/config..."). Matching on the
763
+ // title alone then misses the row that already exists, a SECOND row is created for the new
764
+ // wording, and the first one — with every node it owns — becomes an orphan that the view-side
765
+ // sync then drops. That was "only the running step shows tool types". The fix belongs HERE:
766
+ // reword in place when the slot and lifecycle agree, so the row keeps its ledger.
767
+ if (at === -1) {
768
+ const slotRow = steps[steps.length > todos.indexOf(item) ? todos.indexOf(item) : -1];
769
+ const sameSlot = slotRow && (slotRow.status === item.status || slotRow.status === STEP_STATUS.running);
770
+ if (sameSlot) at = steps.indexOf(slotRow);
771
+ }
772
+
773
+ if (at === -1) {
774
+ const created = newStep(steps.length, item.content, time, "todo");
775
+ if (planIndex !== null) created.planIndex = planIndex;
776
+ // The row's status decides the step's status; a step nobody has started yet must not
777
+ // look like it is running, or every node in the session would land on the LAST row.
778
+ if (item.status === "completed") {
779
+ created.status = STEP_STATUS.done;
780
+ created.endedAt = time;
781
+ created.percent = 100;
782
+ created.percentSource = "closed";
783
+ created.maxPercent = 100;
784
+ } else if (item.status === "in_progress") {
785
+ created.status = STEP_STATUS.running;
786
+ } else {
787
+ created.status = STEP_STATUS.pending;
788
+ created.startedAt = 0;
789
+ }
790
+ steps.push({ ...created, title: text(stripOrdinal(item.content) || item.content, TITLE_LIMIT) });
791
+ continue;
792
+ }
793
+
794
+ const step = steps[at];
795
+ // a matched row takes the CURRENT wording (the model may have reworded it) and keeps
796
+ // its old key, so the nodes it already owns can follow the rename. Both the rename and the
797
+ // lifecycle change are applied in one assignment, so neither can lose the other.
798
+ let next = step;
799
+ const shown = text(stripOrdinal(item.content) || item.content, TITLE_LIMIT);
800
+ const shownKey = normalizeTitle(shown);
801
+ if (shownKey !== "" && shownKey !== next.stepKey) {
802
+ // `aliasKeys` ACCUMULATES every key this row has ever had. A model that rewrites the same row
803
+ // more than once (measured on this session: step 1 was reworded three times) would otherwise
804
+ // lose the older keys, and any node still carrying one would be dropped as an orphan.
805
+ const seen = Array.isArray(next.aliasKeys) ? next.aliasKeys : [];
806
+ const aliasKeys = [...new Set([...seen, next.stepKey, shownKey])].filter((key) => key !== "");
807
+ next = { ...next, title: shown, prevKey: next.stepKey, stepKey: shownKey, aliasKeys };
808
+ }
809
+ if (item.status === "completed" && next.status !== STEP_STATUS.done) {
810
+ steps[at] = closeStep(next, time);
811
+ if (next.startedAt > 0) durations = [...durations, time - next.startedAt].filter((value) => value > 0);
812
+ } else if (item.status === "in_progress" && next.status !== STEP_STATUS.running) {
813
+ // A step re-opened by a re-plan or a retry: keep its ledger row, restart the clock.
814
+ steps[at] = { ...next, status: STEP_STATUS.running, endedAt: null, startedAt: next.startedAt || time };
815
+ } else if (next !== step) {
816
+ steps[at] = next;
817
+ }
818
+ }
819
+
820
+
821
+ return { ...state, asOf: time, todos, steps: reindex(steps), durations };
822
+ }
823
+
824
+ function foldToolCall(state, event, time, seq) {
825
+ const data = dataOf(event);
826
+ const name = data && typeof data.name === "string" ? data.name : "";
827
+ const args = data ? parseArgs(data.arguments) : null;
828
+ const callId = data && typeof data.callId === "string" ? data.callId : null;
829
+ if (name === "") return state;
830
+
831
+ const files = filesFor(name, args);
832
+ const node = newNode("tool", name, toolTitle(name, args), time, {
833
+ seq,
834
+ key: toolKey(name, args),
835
+ detail: argsSummary(name, args),
836
+ argsText: argsDigest(args),
837
+ files,
838
+ callId,
839
+ });
840
+ // `ask_user_question` is the tool the model calls to ASK the user something. It must
841
+ // produce a `question` node (not a plain `tool` node), because that is what the needs-confirmation flag
842
+ // reads — and the settled/answered state comes from the same call's tool result below.
843
+ // Measured 2026-10-18: 87 such calls across the logs, every one answered by its own result
844
+ // except one whose result is the pending notice.
845
+ if (name === ASK_USER_QUESTION_TOOL) {
846
+ const questions = args && Array.isArray(args.questions) ? args.questions : [];
847
+ const first = questions.find(isRecord);
848
+ const label =
849
+ first && typeof first.question === "string"
850
+ ? first.question
851
+ : first && typeof first.header === "string"
852
+ ? first.header
853
+ : "等待用户回答";
854
+ const asked = state.nodes.filter((node) => node.kind === "question" && node.callId === callId);
855
+ const withQuestion = {
856
+ ...state,
857
+ nodes: state.nodes.filter((node) => !asked.includes(node)),
858
+ };
859
+ const question = newNode("question", name, label, time, {
860
+ seq,
861
+ key: `ask:${callId || ""}`,
862
+ detail: `${questions.length} 个问题`,
863
+ callId,
864
+ answered: false,
865
+ files: [],
866
+ });
867
+ return appendNode(withQuestion, question, time);
868
+ }
869
+ const next = appendNode(state, node, time);
870
+ const branchKind = branchKindForTool(name);
871
+ if (branchKind === "") return next;
872
+ // A launcher call (subagent / workflow) opens the lane in the SAME fold: the call IS the
873
+ // start, because the subagent bus event never reaches a recorded session log (header).
874
+ return openLauncherBranch(next, branchKind, name, args, callId, time, seq);
875
+ }
876
+
877
+ function foldToolResult(state, event, time, seq) {
878
+ const data = dataOf(event);
879
+ const message = data && isRecord(data.message) ? data.message : null;
880
+ const callId = message && typeof message.toolCallId === "string" ? message.toolCallId : null;
881
+ const isError = message ? message.isError === true : false;
882
+ if (callId === null) return state;
883
+
884
+ // The matching call: the most recent node with this callId that has no result yet.
885
+ let targetIndex = -1;
886
+ for (let i = state.nodes.length - 1; i >= 0; i -= 1) {
887
+ const node = state.nodes[i];
888
+ if (node.callId === callId && node.resultHead === "" && node.error === "") {
889
+ targetIndex = i;
890
+ break;
891
+ }
892
+ }
893
+ if (targetIndex === -1) return state;
894
+
895
+ const content = message && Array.isArray(message.content) ? message.content : [];
896
+ const body = resultText(content);
897
+ const nodes = [...state.nodes];
898
+ const previous = nodes[targetIndex];
899
+ nodes[targetIndex] = {
900
+ ...previous,
901
+ resultHead: isError ? "" : line(body),
902
+ error: isError ? line(body) : "",
903
+ durationMs: previous.at > 0 && time > previous.at ? time - previous.at : null,
904
+ };
905
+ const answered = { ...state, asOf: time, nodes };
906
+ // a question node is settled by its OWN result. An error result settles it too (the
907
+ // user saw something and the loop moved on); only the PENDING notice leaves it open.
908
+ const settled =
909
+ previous.kind === "question"
910
+ ? {
911
+ ...answered,
912
+ nodes: answered.nodes.map((node, at) =>
913
+ at === targetIndex && !/pending|timeout|no answer batch/i.test(body) ? { ...node, answered: true } : node,
914
+ ),
915
+ }
916
+ : answered;
917
+ const next = withActive(settled, previous.id, time);
918
+ // A tool result is the moment two branch decisions become possible: a job that is STILL
919
+ // open (background) is promoted to a lane, and a launcher's acknowledgement attaches its
920
+ // run id. Both are no-ops for ordinary calls.
921
+ return settleLauncherBranch(promoteJobLane(next, callId, time), callId, time, isError, body);
922
+ }
923
+
924
+ function foldStepStart(state, event, time) {
925
+ const data = dataOf(event);
926
+ const modelStep = data && isFiniteNumber(data.step) ? data.step : null;
927
+ if (modelStep === null) return state;
928
+ // A `step/start` (data = { turn, step }) is a MODEL step, not a plan step: one turn holds
929
+ // dozens of them (measured: 128 in a 4-turn session). Treating it as a plan-step boundary
930
+ // would close every plan step after its first model step, so this only moves the activity
931
+ // pointer and stamps the running plan step. No node is created: 128 markers would bury
932
+ // the real work in the flow column.
933
+ const step = runningStep(state) ?? state.steps[state.steps.length - 1] ?? null;
934
+ if (!step) return { ...state, asOf: time };
935
+ const steps = state.steps.map((row) =>
936
+ row.index === step.index ? { ...row, modelStep, lastActivityAt: time, lastEstimateAt: time } : row,
937
+ );
938
+ return { ...state, asOf: time, steps };
939
+ }
940
+
941
+ function foldStepEnd(state, _event, time) {
942
+ // Activity bookkeeping only — see foldStepStart for why this is not a step boundary.
943
+ const step = runningStep(state) ?? state.steps[state.steps.length - 1] ?? null;
944
+ if (!step) return { ...state, asOf: time };
945
+ const steps = state.steps.map((row) => (row.index === step.index ? { ...row, lastActivityAt: time } : row));
946
+ return { ...state, asOf: time, steps };
947
+ }
948
+
949
+ function foldAssistant(state, event, time, seq) {
950
+ const data = dataOf(event);
951
+ const message = data && isRecord(data.message) ? data.message : null;
952
+ const content = message && Array.isArray(message.content) ? message.content : [];
953
+ const textBlock = content.find((block) => isRecord(block) && block.type === "text" && typeof block.text === "string");
954
+ const calls = content.filter((block) => isRecord(block) && block.type === "tool-call");
955
+ if (!textBlock && calls.length === 0) return state;
956
+ const node = newNode("message", "", textBlock ? line(textBlock.text) : `发起 ${calls.length} 个工具调用`, time, {
957
+ seq,
958
+ detail: calls.length > 0 ? calls.map((call) => (typeof call.name === "string" ? call.name : "")).filter(Boolean).join(", ") : "",
959
+ });
960
+ return appendNode(state, node, time);
961
+ }
962
+
963
+ function foldUser(state, event, time, seq) {
964
+ const data = dataOf(event);
965
+ const content = data && Array.isArray(data.content) ? data.content : [];
966
+ const block = content.find((item) => isRecord(item) && item.type === "text" && typeof item.text === "string");
967
+ if (!block) return state;
968
+ const source = data && isRecord(data.source) ? data.source : {};
969
+ const kind = typeof source.kind === "string" ? source.kind : "";
970
+
971
+ // A settled subagent reports through a USER message, not a tool result (measured in this
972
+ // session's log: `source.kind === "subagent-settled"` with `senderSessionId` = the child's
973
+ // id, and the child's final answer first arrives as `source.kind === "agent-message"`).
974
+ // THIS is what closes the lane, so "a subtask ending turns the status into done/failed" holds in a real log.
975
+ if (kind === "subagent-settled" || kind === "agent-message" || kind === "workflow-settled") {
976
+ const childId = typeof source.senderSessionId === "string" ? source.senderSessionId : "";
977
+ const open = openBranch(state, (branch) => branch.runId !== "" && branch.runId === childId);
978
+ if (!open) return state;
979
+ if (kind === "agent-message") {
980
+ return addBranchNode(
981
+ state,
982
+ open.id,
983
+ { kind: "message", title: "子代理消息", detail: text(block.text), status: STEP_STATUS.running },
984
+ time,
985
+ );
986
+ }
987
+ const summary = typeof source.summary === "string" ? source.summary : "";
988
+ // Only the SUMMARY decides failure: the relayed body is the child's own prose and may
989
+ // legitimately contain the word "error" without the run having failed.
990
+ const failed = /fail|error|cancel|失败/i.test(summary);
991
+ const next = addBranchNode(
992
+ state,
993
+ open.id,
994
+ {
995
+ kind: "end",
996
+ title: failed ? "子代理失败" : "子代理结束",
997
+ detail: text(summary || block.text),
998
+ status: failed ? STEP_STATUS.failed : STEP_STATUS.done,
999
+ },
1000
+ time,
1001
+ );
1002
+ return closeBranch(next, open.id, time, failed ? STEP_STATUS.failed : "done");
1003
+ }
1004
+
1005
+ // The FIRST genuine user message is the task text. Everything else that carries a
1006
+ // `source.kind` (agent instructions, runtime context, the skill catalogue, the model
1007
+ // selector) is host plumbing: it must never become the task and never becomes a node.
1008
+ if (state.task === "" && kind === "user") {
1009
+ // The task text is the view's HEADER, not a flow node: a node called "task start" would add
1010
+ // a step-shaped row that says nothing about the work that was actually done.
1011
+ return { ...state, asOf: time, task: text(block.text, TITLE_LIMIT) };
1012
+ }
1013
+
1014
+ if (kind.startsWith(PAE_SOURCE_PREFIX)) {
1015
+ // PAE's own context messages, written with source.kind = plugin:dsh-plan-and-execute
1016
+ // (measured in this session's log). Four shapes, told apart by `source.summary` — the
1017
+ // label PAE itself puts on the instruction (its prompts.ts, lines 273-329):
1018
+ // "plan-and-execute: start planning (...)" — the kickoff, opens the planning phase
1019
+ // "plan-and-execute: plan summary" — the plan rows: the step list, anchored at its seq
1020
+ // "plan-and-execute: executing step i/N (...)" — the step instruction, opens step i
1021
+ // "plan-and-execute: step i/N result" — step i's report: closes it, records the summary
1022
+ // These are the ONLY reliable plan-step boundaries: they are what PAE's own
1023
+ // orchestrator.json anchorSeqs point at, so can confirm them later.
1024
+ const summary = typeof source.summary === "string" ? source.summary : "";
1025
+ const instruction = parseStepInstruction(block.text, summary);
1026
+ if (instruction) {
1027
+ const opened = openPlanStep(state, time, seq, instruction.planIndex, instruction.title, block.text);
1028
+ return opened;
1029
+ }
1030
+ const report = parseStepReport(summary) ?? parseStepReport(block.text);
1031
+ if (report) return foldPaeReport(state, time, seq, report, block.text);
1032
+ const planRows = parsePlanRows(block.text);
1033
+ if (planRows.length > 1) {
1034
+ const planned = ensurePlanSteps(state, time, planRows);
1035
+ return openPlanStep(planned, time, seq, planRows[0].planIndex, planRows[0].title, block.text);
1036
+ }
1037
+ return appendNode(
1038
+ { ...state, asOf: time },
1039
+ newNode("message", "", summary || "PAE 指令", time, { seq, detail: text(block.text, TEXT_LIMIT) }),
1040
+ time,
1041
+ );
1042
+ }
1043
+ // A message THIS plugin injected has now been admitted by the session itself.
1044
+ // Its id is the one the splice carried, so the history row's status can flip from
1045
+ // "entered the inbox" to "session picked it up" on measured evidence rather than on a guess.
1046
+ if (kind === SELF_SOURCE_KIND && typeof data.id === "string" && data.id !== "") {
1047
+ return markInjectPickedUp({ ...state, asOf: time }, data.id, time);
1048
+ }
1049
+
1050
+ return state;
1051
+ }
1052
+
1053
+ /**
1054
+ * Plan-step bookkeeping.
1055
+ *
1056
+ * A plan step has TWO identities that must not drift apart: the ordinal PAE prints
1057
+ * ("executing plan step 3/14") and the title the plan and the todo table both carry. `planIndex`
1058
+ * holds the ordinal; `normalizeTitle` compares titles. Either one matching is enough to
1059
+ * recognise the same step, which matters because the todo table is written BEFORE the plan
1060
+ * summary and again after every step.
1061
+ */
1062
+
1063
+ /** "3. host projection: ..." -> 3, and "12, x" / "12) x" alike; null when the row carries no ordinal. */
1064
+ function planIndexOf(content) {
1065
+ const match = /^\s*(\d+)\s*[.、.)]\s*\S/.exec(content);
1066
+ return match ? Number(match[1]) : null;
1067
+ }
1068
+
1069
+ /** A title with its ordinal and decoration removed, for comparison only (never displayed). */
1070
+ function normalizeTitle(value) {
1071
+ return typeof value === "string"
1072
+ ? value.replace(/^\s*\d+\s*[.、.)]\s*/, "").replace(/[((][^))]*[))]\s*$/, "").replace(/\s+/g, "").trim()
1073
+ : "";
1074
+ }
1075
+ /**
1076
+ * How many leading characters two normalised titles share. Used to recognise a row the
1077
+ * model REWORDED between two `todo/write` events (it does this often: "read-only inventory: read plugin sources..." ->
1078
+ * "read-only inventory: sources/config..."). Rewording must reuse the existing ledger row, or the old row keeps the
1079
+ * nodes and the new one starts empty — the user-visible symptom was "only the running step shows
1080
+ * tool types".
1081
+ */
1082
+ function commonPrefix(left, right) {
1083
+ const a = typeof left === "string" ? left : "";
1084
+ const b = typeof right === "string" ? right : "";
1085
+ let at = 0;
1086
+ while (at < a.length && at < b.length && a[at] === b[at]) at += 1;
1087
+ return at;
1088
+ }
1089
+
1090
+ /**
1091
+ * Shortest shared prefix (normalised chars) that still means "the same row, reworded", AND the
1092
+ * share of the shorter title it has to cover. 6 chars alone was wrong: "read-only inventory: sources..." vs
1093
+ * "read-only inventory: read plugin sources..." share only "read-only inventory" + a colon (4-5 chars after normalisation), so a
1094
+ * reworded row was treated as deleted and its nodes fell onto the neighbouring step. Requiring
1095
+ * 60 % of the shorter title as well accepts that pair (5/8) without gluing unrelated rows together
1096
+ * (two rows sharing a boilerplate opening still differ in the rest of the title).
1097
+ */
1098
+ const REWORD_PREFIX_MIN = 4;
1099
+ const REWORD_SHARE_MIN = 0.6;
1100
+
1101
+ /**
1102
+ * The title a step is DISPLAYED with: the todo row's own list ordinal is dropped.
1103
+ * "12. panel settings and export" becomes "panel settings and export" — the panel draws its own numbering, and two
1104
+ * numbering systems on one card read as a mistake. The PAE plan titles already have no ordinal.
1105
+ */
1106
+ function stripOrdinal(value) {
1107
+ return typeof value === "string" ? value.replace(/^\s*\d+\s*[.、.)]\s*/, "").trim() : "";
1108
+ }
1109
+
1110
+ /** Re-number `index` after a step list has been rebuilt (index === position, always). */
1111
+ function reindex(steps) {
1112
+ return steps.map((step, index) => (step.index === index ? step : { ...step, index }));
1113
+ }
1114
+
1115
+ /** "executing plan step 3/14: title" (PAE stepInstruction) or its summary form. */
1116
+ function parseStepInstruction(blockText, summary) {
1117
+ const fromText = /^执行计划第\s*(\d+)\s*\/\s*(\d+)\s*步[::]\s*(.+)$/m.exec(blockText);
1118
+ if (fromText) return { planIndex: Number(fromText[1]), total: Number(fromText[2]), title: text(fromText[3], TITLE_LIMIT) };
1119
+ const fromSummary = /执行第\s*(\d+)\s*\/\s*(\d+)\s*步[((](.+)[))]/.exec(summary);
1120
+ if (fromSummary) return { planIndex: Number(fromSummary[1]), total: Number(fromSummary[2]), title: text(fromSummary[3], TITLE_LIMIT) };
1121
+ return null;
1122
+ }
1123
+
1124
+ /** "[plan-and-execute previous result: step 3/14 (title)]" -> { planIndex: 3 }. */
1125
+ function parseStepReport(value) {
1126
+ const match = /第\s*(\d+)\s*\/\s*(\d+)\s*步[((]([^))]+)[))]/.exec(value);
1127
+ if (!match) return null;
1128
+ return { planIndex: Number(match[1]), total: Number(match[2]), title: text(match[3], TITLE_LIMIT) };
1129
+ }
1130
+
1131
+ /** The plan rows of "[plan-and-execute plan summary]" - "1. title (step-01-x.md)" per line. */
1132
+ function parsePlanRows(blockText) {
1133
+ const rows = [];
1134
+ for (const raw of blockText.split("\n")) {
1135
+ const match = /^\s*(\d+)\.\s*(.+)$/.exec(raw);
1136
+ if (!match) continue;
1137
+ rows.push({ planIndex: Number(match[1]), title: text(match[2], TITLE_LIMIT) });
1138
+ if (rows.length >= 80) break;
1139
+ }
1140
+ return rows;
1141
+ }
1142
+
1143
+ /** Make sure every plan row has a step, without disturbing the ones already there. */
1144
+ function ensurePlanSteps(state, time, rows) {
1145
+ let steps = [...state.steps];
1146
+ for (const row of rows) {
1147
+ const at = steps.findIndex(
1148
+ (step) => step.planIndex === row.planIndex || normalizeTitle(step.title) === normalizeTitle(row.title),
1149
+ );
1150
+ if (at === -1) {
1151
+ const created = newStep(steps.length, row.title, 0, "pae");
1152
+ created.planIndex = row.planIndex;
1153
+ created.status = STEP_STATUS.pending;
1154
+ steps.push(created);
1155
+ } else if (steps[at].planIndex === undefined) {
1156
+ steps[at] = { ...steps[at], planIndex: row.planIndex };
1157
+ }
1158
+ }
1159
+ return { ...state, steps: reindex(steps) };
1160
+ }
1161
+
1162
+ /**
1163
+ * The seq a plan step should be anchored at: PAE's own anchor for step i+1 is the report
1164
+ * message of step i (its `anchorSeqs[i+1]`), which `foldPaeReport` parked in `pendingAnchor`.
1165
+ * Without one, the instruction message's own seq is the best evidence available.
1166
+ */
1167
+ function anchorOf(state, planIndex, seq) {
1168
+ const pending = state.pendingAnchor;
1169
+ if (pending && pending.planIndex === planIndex && isFiniteNumber(pending.seq)) return pending.seq;
1170
+ return seq === null ? undefined : seq;
1171
+ }
1172
+ /**
1173
+ * Open plan step `planIndex` at `seq`: close whatever was running, then make this step the
1174
+ * running one (creating it when the todo table has not produced it yet). The anchor is set
1175
+ * once and never moved — a retried instruction must not re-anchor the step.
1176
+ */
1177
+ function openPlanStep(state, time, seq, planIndex, title, blockText) {
1178
+ let steps = [...state.steps];
1179
+ let durations = state.durations;
1180
+ const targetAt = steps.findIndex((step) => step.planIndex === planIndex);
1181
+ const runningAt = steps.findIndex((step) => step.status === STEP_STATUS.running);
1182
+
1183
+ if (runningAt !== -1 && runningAt !== targetAt) {
1184
+ const closing = steps[runningAt];
1185
+ // The synthetic planning step (created for a pre-plan node) is closed but keeps its row:
1186
+ // it is where the research that PRODUCED the plan lives.
1187
+ steps[runningAt] = closeStep(closing, time);
1188
+ durations = [...durations, time - closing.startedAt].filter((value) => value > 0);
1189
+ }
1190
+
1191
+ if (targetAt === -1) {
1192
+ const created = newStep(steps.length, title || `第 ${planIndex} 步`, time, "pae");
1193
+ created.planIndex = planIndex;
1194
+ created.anchorSeq = anchorOf(state, planIndex, seq);
1195
+ steps.push(created);
1196
+ } else {
1197
+ const step = steps[targetAt];
1198
+ steps[targetAt] = {
1199
+ ...step,
1200
+ planIndex,
1201
+ title: title || step.title,
1202
+ anchorSeq: step.anchorSeq === undefined ? anchorOf(state, planIndex, seq) : step.anchorSeq,
1203
+ status: STEP_STATUS.running,
1204
+ endedAt: null,
1205
+ startedAt: step.startedAt || time,
1206
+ reportSummary: step.reportSummary || text(blockText, TITLE_LIMIT),
1207
+ };
1208
+ }
1209
+
1210
+ return withActive({ ...state, asOf: time, steps: reindex(steps), durations }, null, time);
1211
+ }
1212
+
1213
+ /** Step i reported its outcome: close it, keep the report text and the success/failure flag. */
1214
+ function foldPaeReport(state, time, seq, report, blockText) {
1215
+ let steps = [...state.steps];
1216
+ let durations = state.durations;
1217
+ const at = steps.findIndex((step) => step.planIndex === report.planIndex);
1218
+ if (at !== -1) {
1219
+ const step = steps[at];
1220
+ const wasRunning = step.status === STEP_STATUS.running;
1221
+ steps[at] = {
1222
+ ...(wasRunning ? closeStep(step, time) : step),
1223
+ reportSummary: text(blockText, TITLE_LIMIT),
1224
+ outcome: /状态:\s*成功/.test(blockText) ? "success" : /状态:\s*失败/.test(blockText) ? "failed" : step.outcome ?? "",
1225
+ };
1226
+ if (wasRunning) durations = [...durations, time - step.startedAt].filter((value) => value > 0);
1227
+ }
1228
+ // PAE's own anchor for step i+1 IS this report message (orchestrator.json anchorSeqs[i+1]
1229
+ // is exactly its seq), so remember it: when the next instruction opens that step it can
1230
+ // anchor on the same event PAE did, and orchestrator read confirms it instead of
1231
+ // correcting it.
1232
+ const pendingAnchor = { planIndex: report.planIndex + 1, seq: seq === null ? null : seq };
1233
+ return withActive({ ...state, asOf: time, steps: reindex(steps), durations, pendingAnchor }, null, time);
1234
+ }
1235
+
1236
+
1237
+ /**
1238
+ * Close a step at `time`.
1239
+ *
1240
+ * A finished step is 100% — that is what "done" MEANS — so the bar fills and stays filled.
1241
+ * Keeping the last baseline guess here would show a completed step at 83% forever, which is
1242
+ * the one thing a workflow map must not do. The model's own last report is preserved on the
1243
+ * step as `maxPercent`, so a UI that wants "the last self-report" can still show it.
1244
+ */
1245
+ function closeStep(step, time) {
1246
+ return {
1247
+ ...step,
1248
+ status: STEP_STATUS.done,
1249
+ endedAt: time,
1250
+ percent: 100,
1251
+ percentSource: step.percentSource === "model" ? "model" : "closed",
1252
+ maxPercent: Math.max(step.maxPercent, 100),
1253
+ };
1254
+ }
1255
+
1256
+ function foldTurnStart(state, _event, time) {
1257
+ // A new turn does not close the current step (PAE re-sends the todo table each turn),
1258
+ // but it does clear the "current node" highlight: the next node opens it again.
1259
+ // and it opens the turn, which is what `view.running` reports.
1260
+ return withActive({ ...state, asOf: time, turnActive: true }, null, time);
1261
+ }
1262
+
1263
+ function foldTurnEnd(state, _event, time) {
1264
+ return { ...state, asOf: time, turnActive: false };
1265
+ }
1266
+
1267
+ function foldReport(state, event, time) {
1268
+ const data = dataOf(event);
1269
+ const raw = data ? data.percent : undefined;
1270
+ if (!isFiniteNumber(raw)) return state;
1271
+ const note = data && typeof data.note === "string" ? data.note : "";
1272
+ // the report belongs to the step the TODO TABLE says is in progress (the model's own
1273
+ // "this is what I am doing now"), and only falls back to the running row. Before this, a
1274
+ // stale synthetic "planning & research" row won every report: the user saw one bar jump to 45 % on the
1275
+ // planning card while the real step stayed on an indeterminate streamer.
1276
+ const inProgress = inProgressStepIndex(state);
1277
+ const step =
1278
+ (isFiniteNumber(inProgress) && state.steps[inProgress] ? state.steps[inProgress] : null) ??
1279
+ runningStep(state) ??
1280
+ state.steps[state.steps.length - 1] ??
1281
+ null;
1282
+ if (!step) return { ...state, asOf: time };
1283
+ const value = clamp(raw, 0, 100);
1284
+ const reports = [...state.reports.filter((entry) => entry.stepIndex !== step.index), { stepIndex: step.index, percent: value }]
1285
+ .slice(-REPORTS_PER_STEP);
1286
+ // a report IS the moment a progress number came into being, so it is the
1287
+ // `lastEstimateAt` stamp the rail's "estimated N minutes ago" segment measures from. Same event time,
1288
+ // no clock read.
1289
+ const stamped = state.steps.map((row) =>
1290
+ row.index === step.index ? { ...row, lastEstimateAt: time } : row,
1291
+ );
1292
+ const next = withPercents({ ...state, asOf: time, steps: stamped, reports }, time);
1293
+ return appendNode(next, newNode("report", "", `进度自报 ${value}%`, time, { detail: text(note), stepIndex: step.index }), time);
1294
+ }
1295
+
1296
+ /**
1297
+ * Branch lanes : a subagent, a workflow run and a background job all run IN
1298
+ * PARALLEL with the plan step that started them, so their activity cannot be folded into
1299
+ * that step's node list — a subagent's own tool calls are not the plan step's calls. A lane
1300
+ * is a small ordered log of activity rows attached to the step that was running when it
1301
+ * started. It deliberately lives OUTSIDE `state.nodes`, so the main flow's node count, its
1302
+ * same-tool groups, the 300 / 1200 caps and the “current activity” highlight are untouched.
1303
+ */
1304
+
1305
+ /** A workflow phase heading inside a lane (a section, not a tool call). */
1306
+ const BRANCH_ROW_PHASE = "phase";
1307
+
1308
+ /** The step a branch belongs to: the running one, else the last one (never invented). */
1309
+ function branchStepIndex(state) {
1310
+ const running = runningStep(state);
1311
+ if (running) return running.index;
1312
+ const last = state.steps[state.steps.length - 1];
1313
+ return last ? last.index : null;
1314
+ }
1315
+
1316
+ /** Tool name -> branch kind. `workflow` is a tool too: calling it launches a workflow run. */
1317
+ function branchKindForTool(tool) {
1318
+ if (tool === "subagent" || tool === "subagent_fork") return "subagent";
1319
+ if (tool === "workflow") return "workflow";
1320
+ return "";
1321
+ }
1322
+
1323
+ /** Open one lane. Bounded: the oldest CLOSED lane makes room when the cap bites. */
1324
+ function pushBranch(state, spec, time) {
1325
+ const branch = {
1326
+ id: `b${state.nextBranchId}`,
1327
+ kind: spec.kind,
1328
+ label: text(spec.label || "", TITLE_LIMIT),
1329
+ status: STEP_STATUS.running,
1330
+ startedAt: isFiniteNumber(spec.startedAt) && spec.startedAt > 0 ? spec.startedAt : time,
1331
+ endedAt: null,
1332
+ stepIndex: isFiniteNumber(spec.stepIndex) ? spec.stepIndex : branchStepIndex(state),
1333
+ seq: spec.seq === undefined ? null : spec.seq,
1334
+ callId: spec.callId || "",
1335
+ runId: spec.runId || "",
1336
+ jobId: spec.jobId || "",
1337
+ count: 0,
1338
+ nodes: [],
1339
+ };
1340
+ const branches = [...state.branches, branch];
1341
+ let droppedBranches = state.droppedBranches;
1342
+ if (branches.length > BRANCH_TOTAL) {
1343
+ const closedAt = branches.findIndex((item) => item.status !== STEP_STATUS.running);
1344
+ branches.splice(closedAt === -1 ? 0 : closedAt, 1);
1345
+ droppedBranches += 1;
1346
+ }
1347
+ return { ...state, asOf: time, branches, droppedBranches, nextBranchId: state.nextBranchId + 1 };
1348
+ }
1349
+
1350
+ /** Append one activity row (head dropped past BRANCH_NODES; `count` stays honest). */
1351
+ function addBranchNode(state, branchId, node, time) {
1352
+ const at = state.branches.findIndex((item) => item.id === branchId);
1353
+ if (at === -1) return state;
1354
+ const branch = state.branches[at];
1355
+ const nodes = [
1356
+ ...branch.nodes,
1357
+ { ...node, id: `${branch.id}.n${branch.count + 1}`, at: node.at === undefined ? time : node.at },
1358
+ ];
1359
+ if (nodes.length > BRANCH_NODES) nodes.splice(0, nodes.length - BRANCH_NODES);
1360
+ const branches = state.branches.slice();
1361
+ branches[at] = { ...branch, nodes, count: branch.count + 1 };
1362
+ return { ...state, asOf: time, branches };
1363
+ }
1364
+
1365
+ /** Patch one row inside a lane (an agent start and its end are the same row). */
1366
+ function patchBranchNode(state, branchId, rowKey, patch, time) {
1367
+ const at = state.branches.findIndex((item) => item.id === branchId);
1368
+ if (at === -1 || rowKey === "") return state;
1369
+ const branch = state.branches[at];
1370
+ let found = false;
1371
+ const nodes = branch.nodes.map((node) => {
1372
+ if (node.key !== rowKey) return node;
1373
+ found = true;
1374
+ return { ...node, ...patch };
1375
+ });
1376
+ if (!found) return state;
1377
+ const branches = state.branches.slice();
1378
+ branches[at] = { ...branch, nodes };
1379
+ return { ...state, asOf: time, branches };
1380
+ }
1381
+
1382
+ /** The newest open lane matching `match`, or null. */
1383
+ function openBranch(state, match) {
1384
+ for (let i = state.branches.length - 1; i >= 0; i -= 1) {
1385
+ const branch = state.branches[i];
1386
+ if (branch.status === STEP_STATUS.running && match(branch)) return branch;
1387
+ }
1388
+ return null;
1389
+ }
1390
+
1391
+ /** Patch one lane in place (every other row keeps its identity). */
1392
+ function patchBranch(state, branchId, patch, time) {
1393
+ const at = state.branches.findIndex((item) => item.id === branchId);
1394
+ if (at === -1) return state;
1395
+ const branches = state.branches.slice();
1396
+ branches[at] = { ...branches[at], ...patch };
1397
+ return { ...state, asOf: time, branches };
1398
+ }
1399
+
1400
+ /** Close a lane. `failed` is the only forced status; anything else closes as done. */
1401
+ function closeBranch(state, branchId, time, status) {
1402
+ const at = state.branches.findIndex((item) => item.id === branchId);
1403
+ if (at === -1) return state;
1404
+ if (state.branches[at].status !== STEP_STATUS.running) return state;
1405
+ const branches = state.branches.slice();
1406
+ branches[at] = {
1407
+ ...branches[at],
1408
+ status: status === STEP_STATUS.failed ? STEP_STATUS.failed : STEP_STATUS.done,
1409
+ endedAt: time,
1410
+ };
1411
+ return { ...state, asOf: time, branches };
1412
+ }
1413
+
1414
+ /** Find the open lane of `kind` for this run, or open one and return its id. */
1415
+ function ensureBranch(state, kind, spec, time) {
1416
+ const wanted = spec.runId || "";
1417
+ const open = openBranch(
1418
+ state,
1419
+ (branch) => branch.kind === kind && (wanted === "" || branch.runId === "" || branch.runId === wanted),
1420
+ );
1421
+ if (open) {
1422
+ // A launcher opened the lane first (tool call before run-start): give it its run id.
1423
+ const attached = open.runId === "" && wanted !== "" ? patchBranch(state, open.id, { runId: wanted }, time) : state;
1424
+ return { state: attached, id: open.id };
1425
+ }
1426
+ const next = pushBranch(state, { ...spec, kind }, time);
1427
+ return { state: next, id: next.branches[next.branches.length - 1].id };
1428
+ }
1429
+
1430
+ /** Label of a launcher call: the description the model wrote, else the tool name. */
1431
+ function launcherLabel(tool, args) {
1432
+ if (!args) return tool;
1433
+ const description = typeof args.description === "string" ? args.description : "";
1434
+ const agent = typeof args.agent === "string" ? args.agent : "";
1435
+ return text(description || agent || tool, TITLE_LIMIT);
1436
+ }
1437
+
1438
+ /** A subagent / workflow LAUNCHER call opens a lane next to its plan step. */
1439
+ function openLauncherBranch(state, kind, tool, args, callId, time, seq) {
1440
+ const opened = pushBranch(
1441
+ state,
1442
+ { kind, label: launcherLabel(tool, args) || (kind === "subagent" ? "子代理" : "工作流"), callId: callId || "", seq },
1443
+ time,
1444
+ );
1445
+ const branch = opened.branches[opened.branches.length - 1];
1446
+ if (!branch || branch.kind !== kind) return opened;
1447
+ return addBranchNode(
1448
+ opened,
1449
+ branch.id,
1450
+ {
1451
+ kind: "start",
1452
+ tool,
1453
+ title: kind === "subagent" ? "子代理启动" : "工作流启动",
1454
+ detail: branch.label,
1455
+ status: STEP_STATUS.running,
1456
+ },
1457
+ time,
1458
+ );
1459
+ }
1460
+
1461
+ /** `tool/result` of a launcher: the subagent / blocking workflow finished — close the lane. */
1462
+ function settleLauncherBranch(state, callId, time, isError, body) {
1463
+ const open = openBranch(state, (branch) => branch.callId !== "" && branch.callId === callId);
1464
+ if (!open) return state;
1465
+ // A BACKGROUND workflow returns its handle immediately; its lane closes on
1466
+ // `tool-workflow/run-end`, never on the launcher's own (instant) result.
1467
+ if (open.kind === "workflow" && open.runId !== "") return state;
1468
+ // A subagent / workflow tool does NOT block: its result is the launch ACKNOWLEDGEMENT
1469
+ // ("started subagent <id>", measured in this session). That is an ATTACH, not a close —
1470
+ // the lane closes on `subagent-settled` (foldUser) or `tool-workflow/run-end`.
1471
+ if (!isError && LAUNCH_ACK.test(String(body).trim())) {
1472
+ const runId = launcherRunId(String(body).trim());
1473
+ return runId === "" ? state : patchBranch(state, open.id, { runId }, time);
1474
+ }
1475
+ const next = addBranchNode(
1476
+ state,
1477
+ open.id,
1478
+ {
1479
+ kind: "end",
1480
+ title: isError ? "失败" : "完成",
1481
+ detail: line(body),
1482
+ status: isError ? STEP_STATUS.failed : STEP_STATUS.done,
1483
+ },
1484
+ time,
1485
+ );
1486
+ return closeBranch(next, open.id, time, isError ? STEP_STATUS.failed : "done");
1487
+ }
1488
+
1489
+ /** `subagent/start` / `subagent/end` (PLUGIN-BUS shape — see the header note). */
1490
+ function foldSubagent(state, event, time, seq, kind) {
1491
+ const data = dataOf(event);
1492
+ if (!data) return state;
1493
+ const runId = typeof data.runId === "string" ? data.runId : typeof data.id === "string" ? data.id : "";
1494
+ const label =
1495
+ typeof data.description === "string"
1496
+ ? data.description
1497
+ : typeof data.label === "string"
1498
+ ? data.label
1499
+ : typeof data.provider === "string"
1500
+ ? data.provider
1501
+ : "";
1502
+ if (kind === "start") {
1503
+ // The launcher tool call usually opened the lane already: attach the run to it rather
1504
+ // than drawing a second lane for one subagent.
1505
+ const open = openBranch(state, (branch) => branch.kind === "subagent" && branch.runId === "");
1506
+ if (open) {
1507
+ const attached = patchBranch(
1508
+ state,
1509
+ open.id,
1510
+ { runId: runId || open.runId, label: open.label || text(label, TITLE_LIMIT) },
1511
+ time,
1512
+ );
1513
+ // The launcher wrote the start row already: attaching the run must NOT add a second
1514
+ // one, or one subagent would read as two starts.
1515
+ return attached;
1516
+ }
1517
+ return addBranchNode(
1518
+ pushBranch(state, { kind: "subagent", label: text(label || "子代理", TITLE_LIMIT), runId, seq }, time),
1519
+ `b${state.nextBranchId}`,
1520
+ { kind: "start", title: "子代理启动", detail: text(label), status: STEP_STATUS.running },
1521
+ time,
1522
+ );
1523
+ }
1524
+ const open = openBranch(state, (branch) => branch.kind === "subagent" && (runId === "" || branch.runId === runId));
1525
+ if (!open) return state;
1526
+ const stopReason = typeof data.stopReason === "string" ? data.stopReason : "";
1527
+ const failed = /fail|error|cancel/i.test(stopReason);
1528
+ const body = Array.isArray(data.lastAssistantMessage)
1529
+ ? resultText(data.lastAssistantMessage)
1530
+ : typeof data.lastAssistantMessage === "string"
1531
+ ? data.lastAssistantMessage
1532
+ : "";
1533
+ const next = addBranchNode(
1534
+ state,
1535
+ open.id,
1536
+ {
1537
+ kind: "end",
1538
+ title: stopReason === "" ? "结束" : `结束 · ${stopReason}`,
1539
+ detail: line(body),
1540
+ status: failed ? STEP_STATUS.failed : STEP_STATUS.done,
1541
+ },
1542
+ time,
1543
+ );
1544
+ return closeBranch(next, open.id, time, failed ? STEP_STATUS.failed : "done");
1545
+ }
1546
+
1547
+ /**
1548
+ * `workflow/*` (PLUGIN-BUS shape): a run start, its phase headings and its end. The phase
1549
+ * title is a SECTION row inside the lane, exactly as §1 asks.
1550
+ */
1551
+ function foldWorkflowBus(state, type, event, time, seq) {
1552
+ const data = dataOf(event);
1553
+ if (!data) return state;
1554
+ const runId = typeof data.runId === "string" ? data.runId : typeof data.id === "string" ? data.id : "";
1555
+ const name =
1556
+ typeof data.name === "string" ? data.name : typeof data.title === "string" ? data.title : typeof data.phase === "string" ? data.phase : "";
1557
+ if (type === TYPE_WORKFLOW_END) {
1558
+ const open = openBranch(state, (branch) => branch.kind === "workflow" && (runId === "" || branch.runId === runId));
1559
+ return open ? closeBranch(state, open.id, time, "done") : state;
1560
+ }
1561
+ const lane = ensureBranch(state, "workflow", { label: text(name || "工作流", TITLE_LIMIT), runId, seq }, time);
1562
+ if (type === TYPE_WORKFLOW_PHASE) {
1563
+ const phase = typeof data.phase === "string" ? data.phase : name;
1564
+ if (phase === "") return lane.state;
1565
+ return addBranchNode(lane.state, lane.id, { kind: BRANCH_ROW_PHASE, title: text(phase, TITLE_LIMIT), status: STEP_STATUS.running }, time);
1566
+ }
1567
+ return addBranchNode(lane.state, lane.id, { kind: "start", title: text(name || "工作流启动", TITLE_LIMIT), status: STEP_STATUS.running }, time);
1568
+ }
1569
+
1570
+ /**
1571
+ * `tool-workflow/*` (SESSION-LOG shape, the one a recorded log actually carries).
1572
+ * run-start -> the lane (labelled with the workflow's meta name), agent-start -> one row
1573
+ * per member (keyed by `seq`, so agent-end updates the SAME row), run-end -> close.
1574
+ */
1575
+ function foldWorkflowRun(state, type, event, time, seq) {
1576
+ const data = dataOf(event);
1577
+ if (!data) return state;
1578
+ const runId = typeof data.runId === "string" ? data.runId : "";
1579
+ if (type === TYPE_TW_RUN_START) {
1580
+ const name = typeof data.name === "string" ? data.name : "工作流";
1581
+ return ensureBranch(state, "workflow", { label: text(name, TITLE_LIMIT), runId, seq }, time).state;
1582
+ }
1583
+ if (type === TYPE_TW_RUN_END) {
1584
+ const open = openBranch(state, (branch) => branch.kind === "workflow" && (runId === "" || branch.runId === runId));
1585
+ if (!open) return state;
1586
+ const stopReason = typeof data.stopReason === "string" ? data.stopReason : "completed";
1587
+ const failed = /error|fail/i.test(stopReason);
1588
+ return closeBranch(state, open.id, time, failed ? STEP_STATUS.failed : "done");
1589
+ }
1590
+ const open = openBranch(state, (branch) => branch.kind === "workflow" && (runId === "" || branch.runId === runId));
1591
+ if (!open) return state;
1592
+ const memberSeq = isFiniteNumber(data.seq) ? String(data.seq) : "";
1593
+ const label = typeof data.label === "string" ? data.label : "成员";
1594
+ const phase = typeof data.phase === "string" ? data.phase : "";
1595
+ const rowKey = memberSeq === "" ? "" : `agent:${memberSeq}`;
1596
+ if (type === TYPE_TW_AGENT_START) {
1597
+ let next = data.phase === undefined
1598
+ ? state
1599
+ : addBranchNode(state, open.id, { kind: BRANCH_ROW_PHASE, title: text(phase, TITLE_LIMIT), status: STEP_STATUS.running }, time);
1600
+ return addBranchNode(
1601
+ next,
1602
+ open.id,
1603
+ { kind: "agent", key: rowKey, title: text(label, TITLE_LIMIT), detail: text(phase), status: STEP_STATUS.running },
1604
+ time,
1605
+ );
1606
+ }
1607
+ // agent-end: the row opened by agent-start (same `seq`) changes status; nothing is added
1608
+ // when that row was dropped by the 40-row cap.
1609
+ const outcome = typeof data.outcome === "string" ? data.outcome : "completed";
1610
+ const rowStatus = outcome === "failed" ? STEP_STATUS.failed : outcome === "cancelled" ? STEP_STATUS.pending : STEP_STATUS.done;
1611
+ const patched = rowKey === "" ? state : patchBranchNode(state, open.id, rowKey, { status: rowStatus, detail: text(outcome) }, time);
1612
+ return patched;
1613
+ }
1614
+
1615
+ /**
1616
+ * `job/registered` / `job/settled` (measured payloads in the header): a background long
1617
+ * task — pwsh run_in_background, a workflow background run — gets its own lane, and the
1618
+ * lane's conclusion comes from `outcome.status`, never from the tool result.
1619
+ */
1620
+ function foldJob(state, event, time, seq, settled) {
1621
+ const data = dataOf(event);
1622
+ if (!data) return state;
1623
+ const jobId = typeof data.jobId === "string" ? data.jobId : "";
1624
+ const rawLabel =
1625
+ typeof data.label === "string" && data.label !== "" ? data.label : typeof data.kind === "string" ? data.kind : "后台任务";
1626
+ if (!settled) {
1627
+ // Registration alone is NOT a lane: a synchronous command registers and settles inside
1628
+ // its own tool call, and 452 such jobs would flood the branch area (measured).
1629
+ const callId = isRecord(data.toolCall) && typeof data.toolCall.callId === "string" ? data.toolCall.callId : "";
1630
+ const pendingJobs = rememberJob(state, {
1631
+ jobId,
1632
+ label: text(rawLabel, TITLE_LIMIT),
1633
+ kind: typeof data.kind === "string" ? text(data.kind, 40) : "",
1634
+ startedAt: isFiniteNumber(data.startedAt) && data.startedAt > 0 ? data.startedAt : time,
1635
+ callId,
1636
+ stepIndex: branchStepIndex(state),
1637
+ });
1638
+ return { ...state, asOf: time, pendingJobs };
1639
+ }
1640
+ const pendingJobs = (Array.isArray(state.pendingJobs) ? state.pendingJobs : []).filter((job) => job.jobId !== jobId);
1641
+ const base = { ...state, asOf: time, pendingJobs };
1642
+ const open = openBranch(base, (branch) => branch.kind === "job" && (jobId === "" || branch.jobId === jobId));
1643
+ if (!open) return base;
1644
+ const outcome = isRecord(data.outcome) ? data.outcome : null;
1645
+ const raw = outcome && typeof outcome.status === "string" ? outcome.status : "completed";
1646
+ const failed = raw === "failed";
1647
+ const detailParts = [];
1648
+ if (outcome && typeof outcome.detail === "string" && outcome.detail !== "") detailParts.push(text(outcome.detail, TEXT_LIMIT));
1649
+ if (outcome && typeof outcome.result === "string" && outcome.result !== "") detailParts.push(line(outcome.result));
1650
+ const next = addBranchNode(
1651
+ base,
1652
+ open.id,
1653
+ {
1654
+ kind: "end",
1655
+ title: raw === "completed" ? "任务完成" : raw === "killed" ? "任务中止" : "任务失败",
1656
+ detail: text(detailParts.join(" · "), TEXT_LIMIT),
1657
+ status: failed ? STEP_STATUS.failed : STEP_STATUS.done,
1658
+ },
1659
+ time,
1660
+ );
1661
+ return closeBranch(next, open.id, time, failed ? STEP_STATUS.failed : "done");
1662
+ }
1663
+
1664
+ /** Longest list of registered-but-open jobs kept (a lane is born only from one of them). */
1665
+ const JOB_PENDING_LIMIT = 24;
1666
+
1667
+ /** A launcher's result is an ACKNOWLEDGEMENT, not a completion: "started subagent <id>". */
1668
+ const LAUNCH_ACK = /^started\b/i;
1669
+
1670
+ /** The run id a launch acknowledgement carries ("started subagent <id>"). */
1671
+ function launcherRunId(value) {
1672
+ const match =
1673
+ /started\s+(?:sub)?agent\s+([\w-]{4,})/i.exec(value) ||
1674
+ /started\s+workflow\s+([\w.-]{4,})/i.exec(value) ||
1675
+ /started\s+([\w-]{4,})/i.exec(value);
1676
+ return match ? match[1] : "";
1677
+ }
1678
+
1679
+ /** The pending job launched by this tool call, or null. */
1680
+ function pendingJobOf(state, callId) {
1681
+ if (callId === "") return null;
1682
+ const rows = Array.isArray(state.pendingJobs) ? state.pendingJobs : [];
1683
+ for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i].callId === callId) return rows[i];
1684
+ return null;
1685
+ }
1686
+
1687
+ /** Remember a registered job (bounded; the same job id replaces its old row). */
1688
+ function rememberJob(state, job) {
1689
+ const rows = (Array.isArray(state.pendingJobs) ? state.pendingJobs : []).filter((row) => row.jobId !== job.jobId);
1690
+ rows.push(job);
1691
+ return rows.slice(-JOB_PENDING_LIMIT);
1692
+ }
1693
+
1694
+ /**
1695
+ * Promote a PENDING job to a lane when its tool result arrives while the job is still open.
1696
+ *
1697
+ * MEASURED in this session's own log: every `pwsh` call ALSO registers a job and settles it
1698
+ * BEFORE its own tool result (452 registrations in one session), so drawing a lane per
1699
+ * registration would bury the real parallel work under synchronous commands. A background
1700
+ * task is exactly the opposite order — result first, settlement later — and that is the only
1701
+ * case that becomes a lane (a backend long-running task).
1702
+ */
1703
+ function promoteJobLane(state, callId, time) {
1704
+ const pending = pendingJobOf(state, callId);
1705
+ if (!pending) return state;
1706
+ const opened = pushBranch(
1707
+ state,
1708
+ { kind: "job", label: pending.label, jobId: pending.jobId, startedAt: pending.startedAt, stepIndex: pending.stepIndex },
1709
+ time,
1710
+ );
1711
+ const lane = opened.branches[opened.branches.length - 1];
1712
+ const next =
1713
+ lane && lane.jobId === pending.jobId
1714
+ ? addBranchNode(
1715
+ opened,
1716
+ lane.id,
1717
+ { kind: "start", title: "后台任务启动", detail: text(pending.kind), status: STEP_STATUS.running },
1718
+ time,
1719
+ )
1720
+ : opened;
1721
+ return { ...next, pendingJobs: next.pendingJobs.filter((job) => job.jobId !== pending.jobId) };
1722
+ }
1723
+ function foldQuestion(state, event, time, seq) {
1724
+ const data = dataOf(event);
1725
+ const questions = data && Array.isArray(data.questions) ? data.questions : [];
1726
+ const first = questions.find(isRecord);
1727
+ const label = first && typeof first.question === "string" ? first.question : "等待用户回答";
1728
+ // the callId is what lets the tool result settle this question. The `user-questions`
1729
+ // request is a BUS event that a recorded log never carries (measured 2026-10-18: 0 hits in
1730
+ // every session log); the question really arrives as a `tool/call` named `ask_user_question`
1731
+ // (87 measured) and is settled by its own `tool/result`. So the node the UI shows comes from
1732
+ // the tool call, and this branch only handles a host that DOES record the bus shape.
1733
+ const callId = data && typeof data.callId === "string" ? data.callId : "";
1734
+ const node = newNode("question", "", label, time, { seq, detail: `${questions.length} 个问题`, callId });
1735
+ return appendNode(state, node, time);
1736
+ }
1737
+
1738
+ function foldApproval(state, event, time, seq) {
1739
+ const data = dataOf(event);
1740
+ const label = data && typeof data.reason === "string" ? data.reason : data && typeof data.title === "string" ? data.title : "等待审批";
1741
+ const node = newNode("approval", "", label, time, { seq });
1742
+ return appendNode(state, node, time);
1743
+ }
1744
+
1745
+ // ---------------------------------------------------------------------------
1746
+ // hot-inject history (the session log is the record — no second write path)
1747
+ // ---------------------------------------------------------------------------
1748
+
1749
+ /** One injected message, bounded and lossless. `pickedUp` is the honest status word. */
1750
+ const INJECT_LIMIT = 40;
1751
+
1752
+ /** The two loop-owned inbox lists, and the ONLY keys the ledger ever holds. */
1753
+ const INBOX_TARGETS = ["next-turn", "next-step"];
1754
+
1755
+ /** A ledger with both lists present and independent (a shared array must never be mutated). */
1756
+ function emptyLedger() {
1757
+ return { "next-turn": [], "next-step": [] };
1758
+ }
1759
+
1760
+ /**
1761
+ * Replay ONE `agent/inbox/spliced` onto the pending-id ledger.
1762
+ *
1763
+ * This mirrors the host's own `inboxProjectionDefinition.apply` (agent-loop `lib/types/inbox.js`):
1764
+ * take the target list, clamp `start` and `removedCount` into it, then `toSpliced`. The one
1765
+ * deliberate difference is the failure mode. The host THROWS on a malformed splice because it can
1766
+ * refuse a corrupt session; a plugin projection must not — a throw here becomes
1767
+ * `SESSION_QUERY_CORRUPT_SESSION` for the whole session and the user loses the history view. So an
1768
+ * unusable splice is treated as a no-op: the ledger only answers "which id did that removal take?",
1769
+ * and a wrong answer there must degrade to “unknown”, never to “history unavailable”.
1770
+ *
1771
+ * Returns `{ ledger, moved, removedIds }`; `moved: false` returns the CALLER's own object so the
1772
+ * fold can keep `state` identity and skip a needless clone.
1773
+ */
1774
+ function inboxApplySplice(inboxIds, data) {
1775
+ const unchanged = { ledger: inboxIds, moved: false, removedIds: [] };
1776
+ if (!isRecord(data) || !INBOX_TARGETS.includes(data.target)) return unchanged;
1777
+ const current = isRecord(inboxIds) ? inboxIds : null;
1778
+ const lists = emptyLedger();
1779
+ for (const target of INBOX_TARGETS) {
1780
+ const list = current !== null && Array.isArray(current[target]) ? current[target] : [];
1781
+ // Copy per target: the input ledger may be shared with a live state that nobody cloned.
1782
+ lists[target] = list.filter((id) => typeof id === "string" && id !== "");
1783
+ }
1784
+ const list = lists[data.target];
1785
+ // Every host-written splice carries `start` (it comes from `mutate(target, start, count, …)`);
1786
+ // an older or hand-made one without it can only mean “append”, which is this fold's own
1787
+ // pre-ledger reading of such an event. Both are clamped rather than rejected: an out-of-range
1788
+ // index means the fold and the loop disagree about the pending list, and clamping keeps the rest
1789
+ // of the ledger usable instead of dropping the session.
1790
+ const rawStart = Number.isSafeInteger(data.start) ? data.start : list.length;
1791
+ const start = Math.min(Math.max(rawStart, 0), list.length);
1792
+ const rawRemoved = Number.isSafeInteger(data.removedCount) && data.removedCount > 0 ? data.removedCount : 0;
1793
+ const removedCount = Math.min(rawRemoved, list.length - start);
1794
+ const insertedIds = (Array.isArray(data.inserted) ? data.inserted : [])
1795
+ .map((message) => (isRecord(message) && typeof message.id === "string" ? message.id : ""))
1796
+ .filter((id) => id !== "");
1797
+ const removedIds = list.slice(start, start + removedCount);
1798
+ if (removedIds.length === 0 && insertedIds.length === 0) return unchanged;
1799
+ lists[data.target] = [...list.slice(0, start), ...insertedIds, ...list.slice(start + removedCount)];
1800
+ return { ledger: lists, moved: true, removedIds };
1801
+ }
1802
+
1803
+ /**
1804
+ * `agent/inbox/spliced { target, start, removedCount?, inserted, outcome? }` — the loop's own
1805
+ * durable record of ONE inbox mutation (the agent-loop README: "Every inbox mutation commits one
1806
+ * normalized agent/inbox/spliced event"). Two jobs are done here, and they are different jobs:
1807
+ *
1808
+ * 1. THE LEDGER. Every splice — whoever produced it, cancelled or not — is replayed positionally
1809
+ * onto `state.inboxIds`, exactly as the loop's own inbox projection replays it. Without this
1810
+ * the fold cannot answer "which message did that removal take?".
1811
+ * 2. OUR ROWS. Only THIS plugin's own messages become history rows: the splice carries the
1812
+ * producer-owned `source.kind`, so a human's prompt, a PAE instruction and a runtime-context
1813
+ * frame are all ignored here (measured 2026-10-18: `target: "next-step"` is what `steer()`
1814
+ * produces). A cancellation (`outcome: 'canceled'`) is resolved through the ledger and flips
1815
+ * the matching row to "canceled" - that is the `POST /dsh-workflow/unsteer` round trip's
1816
+ * durable half.
1817
+ */
1818
+ function foldInboxSpliced(state, event, time) {
1819
+ const data = dataOf(event);
1820
+ const applied = inboxApplySplice(state.inboxIds, data);
1821
+ let next = applied.moved ? { ...state, inboxIds: applied.ledger } : state;
1822
+
1823
+ // A cancellation names an INDEX, not a message: the ledger turns it back into the ids that left.
1824
+ // This runs before the insert loop deliberately — a splice that removes and inserts in one call
1825
+ // (the loop never does, but the shape allows it) must not cancel a row it just re-inserted.
1826
+ if (isRecord(data) && data.outcome === "canceled" && applied.removedIds.length > 0) {
1827
+ for (const id of applied.removedIds) next = markInjectCanceled(next, id, time);
1828
+ }
1829
+
1830
+ const inserted = data && Array.isArray(data.inserted) ? data.inserted : [];
1831
+ for (const message of inserted) {
1832
+ if (!isRecord(message)) continue;
1833
+ const source = isRecord(message.source) ? message.source : {};
1834
+ if (String(source.kind || "") !== SELF_SOURCE_KIND) continue;
1835
+ const block = Array.isArray(message.content)
1836
+ ? message.content.find((item) => isRecord(item) && item.type === "text" && typeof item.text === "string")
1837
+ : null;
1838
+ if (!block) continue;
1839
+ const row = {
1840
+ id: typeof message.id === "string" ? message.id : "",
1841
+ at: time,
1842
+ target: typeof data.target === "string" ? data.target : "",
1843
+ text: text(block.text, TEXT_LIMIT),
1844
+ // The splice proves the message reached the inbox, NOT that the turn consumed it. The
1845
+ // matching `user/message` (same id) is what proves that; until then this stays false.
1846
+ pickedUp: false,
1847
+ };
1848
+ next = { ...next, asOf: time, injects: [...next.injects.filter((item) => item.id !== row.id), row].slice(-INJECT_LIMIT) };
1849
+ }
1850
+ return next === state ? state : next;
1851
+ }
1852
+
1853
+ /**
1854
+ * "revocable while not yet claimed by the session" - the NON-persistent half (erratum).
1855
+ *
1856
+ * This branch answers the host's `dispatch.emit("agent/inbox/discarded", { message })`: it carries the message itself,
1857
+ * but does NOT enter the session log (the agent-loop README says consumers should use claimed/discarded notices, not rely on
1858
+ * the pre-splice session/event view; across 145 real logs the persistent `agent/inbox/discarded` count is 0).
1859
+ * The genuinely persistent revoke record is `agent/inbox/spliced { outcome: "canceled" }`, handled by the previous `foldInboxSpliced`
1860
+ * ledger path - both paths call `markInjectCanceled`, so whichever shape arrives first the UI shows the same
1861
+ * status word, and re-arrival is idempotent (it neither throws nor flips a claimed row back).
1862
+ */
1863
+ function foldInboxDiscarded(state, event, time) {
1864
+ const data = dataOf(event);
1865
+ const message = data && isRecord(data.message) ? data.message : null;
1866
+ if (!message) return state;
1867
+ const id = typeof message.id === "string" ? message.id : "";
1868
+ if (id === "") return state;
1869
+ const source = isRecord(message.source) ? message.source : {};
1870
+ if (String(source.kind || "") !== SELF_SOURCE_KIND) return state;
1871
+ return markInjectCanceled(state, id, time);
1872
+ }
1873
+
1874
+ /** "session picked it up": the session admitted THIS plugin's injected message (same id) ⇒ pickedUp. */
1875
+ function markInjectPickedUp(state, messageId, time) {
1876
+ if (messageId === "") return state;
1877
+ let changed = false;
1878
+ const injects = state.injects.map((row) => {
1879
+ if (row.id !== messageId || row.pickedUp === true) return row;
1880
+ changed = true;
1881
+ return { ...row, pickedUp: true, pickedUpAt: time };
1882
+ });
1883
+ return changed ? { ...state, injects } : state;
1884
+ }
1885
+
1886
+ /**
1887
+ * "canceled": the ledger proved this id is the one the loop's splice removed ⇒ the row is
1888
+ * cancelled, not picked up. Same row shape as the durable `agent/inbox/discarded` path
1889
+ * (`foldInboxDiscarded`) so the panel renders one status word whichever event carried the fact.
1890
+ * A row the session already admitted keeps `pickedUp`: a revocation cannot un-read a message.
1891
+ */
1892
+ function markInjectCanceled(state, messageId, time) {
1893
+ if (messageId === "" || !Array.isArray(state.injects)) return state;
1894
+ let changed = false;
1895
+ const injects = state.injects.map((row) => {
1896
+ if (row.id !== messageId || row.pickedUp === true || row.canceled === true) return row;
1897
+ changed = true;
1898
+ return { ...row, canceled: true, canceledAt: time };
1899
+ });
1900
+ return changed ? { ...state, asOf: time, injects } : state;
1901
+ }
1902
+
1903
+ // ---------------------------------------------------------------------------
1904
+ // the approval audit pair - which decision is still waiting on a human
1905
+ // ---------------------------------------------------------------------------
1906
+
1907
+ /** How many approval rows are kept. Only an unpaired `asked` matters, so decided rows go first. */
1908
+ const APPROVAL_LIMIT = 40;
1909
+
1910
+ /** Attribute an approval event to a step the same way a node is attributed. */
1911
+ function approvalStepIndex(state, seq) {
1912
+ const step = stepForNode(state, seq ?? null, inProgressStepIndex(state));
1913
+ return step ? step.index : null;
1914
+ }
1915
+
1916
+ function foldApprovalAsked(state, event, time, seq) {
1917
+ const data = dataOf(event);
1918
+ const id = data && typeof data.id === "string" ? data.id : "";
1919
+ if (id === "") return state;
1920
+ const stepIndex = approvalStepIndex(state, seq);
1921
+ if (stepIndex === null) return { ...state, asOf: time };
1922
+ const reason = data && typeof data.reason === "string" ? data.reason : "";
1923
+ const tool = data && typeof data.toolName === "string" ? data.toolName : "";
1924
+ const row = { id, state: "asked", stepIndex, label: text(reason || `等待审批 ${tool}`.trim(), TEXT_LIMIT), at: time };
1925
+ // The same id cannot be asked twice (the host's own invariant refuses it), but a replayed
1926
+ // log may carry a duplicate: replacing keeps the row count honest either way.
1927
+ const approvals = [...state.approvals.filter((item) => item.id !== id), row];
1928
+ return { ...state, asOf: time, approvals: trimApprovals(approvals) };
1929
+ }
1930
+
1931
+ function foldApprovalDecided(state, event, time) {
1932
+ const data = dataOf(event);
1933
+ const id = data && typeof data.id === "string" ? data.id : "";
1934
+ if (id === "") return state;
1935
+ const open = state.approvals.some((item) => item.id === id && item.state === "asked");
1936
+ if (!open) return { ...state, asOf: time };
1937
+ const outcome = data && typeof data.outcome === "string" ? data.outcome : "";
1938
+ const approvals = state.approvals.map((item) =>
1939
+ item.id === id ? { ...item, state: "decided", outcome, decidedAt: time } : item,
1940
+ );
1941
+ // A rejected / cancelled decision is NOT a resolved one for our purpose: the step is still
1942
+ // waiting on the human (the host would re-ask on the next call). Only a grant closes it, and
1943
+ // that is expressed by REMOVING the row entirely — the pair is then complete.
1944
+ const granted = outcome === "allowed-once";
1945
+ const kept = granted ? approvals.filter((item) => item.id !== id) : approvals;
1946
+ return { ...state, asOf: time, approvals: trimApprovals(kept) };
1947
+ }
1948
+
1949
+ function trimApprovals(rows) {
1950
+ if (rows.length <= APPROVAL_LIMIT) return rows;
1951
+ const decided = rows.filter((row) => row.state === "decided");
1952
+ const asked = rows.filter((row) => row.state !== "decided");
1953
+ const keep = APPROVAL_LIMIT - asked.length;
1954
+ return [...(keep > 0 ? decided.slice(Math.max(0, decided.length - keep)) : []), ...asked];
1955
+ }
1956
+
1957
+ /**
1958
+ * Settle a question / approval node when its own tool result arrives. A node whose call was
1959
+ * answered is no longer needs-confirmation; a node whose result is the PENDING notice (dsh-tool-ask-user's
1960
+ * timeout) stays open, because the user can still answer later.
1961
+ */
1962
+ function settleConfirmNode(state, callId, body, time) {
1963
+ if (callId === "") return state;
1964
+ const index = state.nodes.findIndex((node) => node.kind === "question" && node.callId === callId);
1965
+ if (index < 0) return state;
1966
+ const answered = !/pending|timeout|no answer batch/i.test(body);
1967
+ if (!answered) return state;
1968
+ const nodes = state.nodes.map((node, at) => (at === index ? { ...node, answered: true } : node));
1969
+ return { ...state, asOf: time, nodes };
1970
+ }
1971
+
1972
+ // ---------------------------------------------------------------------------
1973
+ // node bookkeeping (append, cap, group, active)
1974
+ // ---------------------------------------------------------------------------
1975
+
1976
+ /**
1977
+ * Append one node, enforcing both caps and attributing it to a plan step.
1978
+ * Attribution (step file §3): the PAE anchor range wins; otherwise the in_progress todo
1979
+ * row's step; otherwise the running step. A node that predates every anchor of an anchored
1980
+ * plan belongs to no step — it is dropped rather than guessed into one.
1981
+ */
1982
+ function appendNode(state, node, time) {
1983
+ const step = stepForNode(state, node.seq ?? null, inProgressStepIndex(state));
1984
+ if (!step) {
1985
+ if (state.steps.length === 0) {
1986
+ // No plan yet (the planning phase of an ordinary session): the nodes are still worth
1987
+ // showing, so they open one synthetic step. It is NOT a plan step and says so.
1988
+ const only = newStep(0, PLANNING_TITLE, time, "todo");
1989
+ return appendNode({ ...state, steps: [only] }, node, time);
1990
+ }
1991
+ // An anchored plan exists and this event precedes its first anchor: no step owns it.
1992
+ return { ...state, asOf: time };
1993
+ }
1994
+
1995
+ const nodes = [...state.nodes];
1996
+ node.id = `n${state.nextNodeId}`;
1997
+ // `stepKey` (not `stepIndex`) is the node's identity: the todo-sync re-orders rows on every
1998
+ // `todo/write`, so an index would follow whatever row moved into that slot.
1999
+ node.stepKey = step.stepKey || normalizeTitle(step.title);
2000
+ node.stepIndex = step.index;
2001
+ node.key = node.key || node.tool || node.kind;
2002
+ nodes.push(node);
2003
+
2004
+ let truncatedTotal = state.truncated.totalNodes;
2005
+ let steps = state.steps;
2006
+ if (nodes.length > NODES_TOTAL) {
2007
+ // FAIR EVICTION, not oldest-first. Dropping the OLDEST nodes deleted exactly the
2008
+ // finished steps' work, so their cards read "0 tools 0 files" while a long-running step kept
2009
+ // everything. The global cap now removes nodes from the step with the MOST nodes (oldest first
2010
+ // inside that step), so the cap still bounds the state but no step is emptied.
2011
+ let drop = nodes.length - NODES_TOTAL;
2012
+ truncatedTotal += drop;
2013
+ const byStep = new Map();
2014
+ for (const item of nodes) {
2015
+ if (!byStep.has(item.stepIndex)) byStep.set(item.stepIndex, []);
2016
+ byStep.get(item.stepIndex).push(item);
2017
+ }
2018
+ const evicted = new Set();
2019
+ const aliveCount = new Map();
2020
+ for (const [index, list] of byStep) aliveCount.set(index, list.length);
2021
+ while (drop > 0) {
2022
+ let fattest = null;
2023
+ let fatSize = 0;
2024
+ for (const [index, size] of aliveCount) {
2025
+ if (size > fatSize) {
2026
+ fatSize = size;
2027
+ fattest = index;
2028
+ }
2029
+ }
2030
+ if (fattest === null) break;
2031
+ const list = byStep.get(fattest);
2032
+ const victim = list.find((item) => !evicted.has(item.id));
2033
+ if (!victim) break;
2034
+ evicted.add(victim.id);
2035
+ aliveCount.set(fattest, fatSize - 1);
2036
+ drop -= 1;
2037
+ }
2038
+ if (evicted.size > 0) {
2039
+ const keptNodes = nodes.filter((item) => !evicted.has(item.id));
2040
+ nodes.length = 0;
2041
+ nodes.push(...keptNodes);
2042
+ }
2043
+ const kept = new Set(nodes.map((item) => item.id));
2044
+ steps = steps.map((row) => ({ ...row, nodeIds: row.nodeIds.filter((id) => kept.has(id)) }));
2045
+ }
2046
+
2047
+ // Per-step cap: head + tail kept (the first calls say what the step IS, the last ones say
2048
+ // how it ended), the middle dropped and counted so the UI can say "N nodes hidden".
2049
+ const row = steps[step.index];
2050
+ const allIds = [...row.nodeIds, node.id];
2051
+ let nodeIds = allIds;
2052
+ let truncatedNodes = row.truncatedNodes;
2053
+ let newlyTruncated = 0;
2054
+ if (allIds.length > NODES_PER_STEP) {
2055
+ const head = allIds.slice(0, Math.floor(NODES_PER_STEP / 2));
2056
+ const tail = allIds.slice(allIds.length - Math.ceil(NODES_PER_STEP / 2));
2057
+ nodeIds = [...head, ...tail];
2058
+ const nextTruncated = row.nodeCount + 1 - nodeIds.length;
2059
+ newlyTruncated = Math.max(0, nextTruncated - truncatedNodes);
2060
+ truncatedNodes = nextTruncated;
2061
+ }
2062
+ steps = steps.map((item) =>
2063
+ item.index === step.index ? { ...item, nodeIds, nodeCount: row.nodeCount + 1, truncatedNodes } : item,
2064
+ );
2065
+
2066
+ const truncated = {
2067
+ totalNodes: truncatedTotal,
2068
+ stepNodes: state.truncated.stepNodes + newlyTruncated,
2069
+ };
2070
+ const next = { ...state, asOf: time, nodes, steps, truncated, nextNodeId: state.nextNodeId + 1 };
2071
+ return withActive(next, node.id, time);
2072
+ }
2073
+
2074
+ /** Index of the step that matches the FIRST in_progress todo row, or null. */
2075
+ function inProgressStepIndex(state) {
2076
+ const row = state.todos.find((item) => item.status === "in_progress" && item.content !== "");
2077
+ if (!row) return null;
2078
+ const step = state.steps.find((item) => normalizeTitle(item.title) === normalizeTitle(row.content));
2079
+ return step ? step.index : null;
2080
+ }
2081
+
2082
+ /** Set the active pointer (which step/node the "current activity" highlight follows). */
2083
+ function withActive(state, nodeId, time) {
2084
+ const step = runningStep(state) ?? state.steps[state.steps.length - 1] ?? null;
2085
+ const active = step ? { stepIndex: step.index, nodeId: nodeId === undefined ? null : nodeId } : null;
2086
+ const asOf = isFiniteNumber(time) && time > 0 ? time : state.asOf;
2087
+ return { ...state, asOf, active };
2088
+ }
2089
+
2090
+ /**
2091
+ * Fold consecutive same-key nodes into a group record. The nodes stay in `state.nodes`
2092
+ * (the UI expands a group into them); the group is a summary row only.
2093
+ */
2094
+ function withGroups(state) {
2095
+ const steps = state.steps.map((step) => {
2096
+ const nodes = state.nodes.filter((node) => node.stepIndex === step.index);
2097
+ const groups = [];
2098
+ let run = null;
2099
+ for (const node of nodes) {
2100
+ const key = node.key || node.tool || node.kind;
2101
+ if (run && run.key === key) {
2102
+ run.count += 1;
2103
+ run.lastId = node.id;
2104
+ run.lastAt = node.at;
2105
+ } else {
2106
+ if (run && run.count >= GROUP_THRESHOLD) groups.push({ ...run });
2107
+ run = { key, tool: node.tool, firstId: node.id, lastId: node.id, count: 1, firstAt: node.at, lastAt: node.at };
2108
+ }
2109
+ }
2110
+ if (run && run.count >= GROUP_THRESHOLD) groups.push({ ...run });
2111
+ return { ...step, groups };
2112
+ });
2113
+ return { ...state, steps };
2114
+ }
2115
+
2116
+ // ---------------------------------------------------------------------------
2117
+ // the fold entry point
2118
+ // ---------------------------------------------------------------------------
2119
+
2120
+ /**
2121
+ * Fold one event into the state. Pure, reference-preserving for unrelated events, and
2122
+ * idempotent for a repeated event (guarded by the event's seq).
2123
+ *
2124
+ * @param {object} state - the state to fold into (never mutated).
2125
+ * @param {object} event - one session event.
2126
+ * @returns {object} the next state (the same reference when nothing applied).
2127
+ */
2128
+ export function foldEvent(state, event) {
2129
+ const base = isRecord(state) ? state : createInitialState();
2130
+ if (!isObject(event)) return base;
2131
+ const type = typeof event.type === "string" ? event.type : null;
2132
+ if (type === null || !isRelated(type)) return base;
2133
+
2134
+ const seq = eventSeq(event);
2135
+ // Idempotence: the same event delivered twice must not double-append. The first event
2136
+ // of a log is the `session` header, which is not in RELATED, so seq is usable here.
2137
+ if (seq !== null && base.seq !== null && seq <= base.seq) return base;
2138
+
2139
+ const time = eventTime(event);
2140
+ const folded = foldOne(base, type, event, time, seq);
2141
+ if (folded === base && seq === null) return base;
2142
+ // ONE convergence point for "exactly one running step". Running it here (not inside
2143
+ // foldTodos) is deliberate: a stale synthetic row can survive any event type, and the batch
2144
+ // paths (the host's one-shot replay of a whole log) then get the same invariant for free.
2145
+ // `soleRunning` is a no-op when at most one row is running, so the reference — and with it
2146
+ // the whole idempotence contract — is preserved for ordinary events.
2147
+ // Order matters: the todo-sync runs FIRST (it decides which rows exist and how many nodes each
2148
+ // owns), then the single-running convergence. Both are idempotent, so an event that changes
2149
+ // neither returns the previous reference and the idempotence contract is untouched.
2150
+ const next = soleRunning(syncToTodos(folded), time);
2151
+ return next === base ? base : { ...next, seq: seq === null ? next.seq : seq };
2152
+ }
2153
+
2154
+ function foldOne(state, type, event, time, seq) {
2155
+ switch (type) {
2156
+ case TYPE_TODOS:
2157
+ return withPercents(foldTodos(state, event, time), time);
2158
+ case TYPE_TOOL_CALL:
2159
+ return foldToolCall(state, event, time, seq);
2160
+ case TYPE_TOOL_RESULT:
2161
+ return foldToolResult(state, event, time, seq);
2162
+ case TYPE_STEP_START:
2163
+ return foldStepStart(state, event, time, seq);
2164
+ case TYPE_STEP_END:
2165
+ return foldStepEnd(state, event, time);
2166
+ case TYPE_ASSISTANT:
2167
+ return foldAssistant(state, event, time, seq);
2168
+ case TYPE_USER:
2169
+ return foldUser(state, event, time, seq);
2170
+ case TYPE_TURN_START:
2171
+ return foldTurnStart(state, event, time);
2172
+ case TYPE_TURN_END:
2173
+ return foldTurnEnd(state, event, time);
2174
+ case TYPE_REPORT:
2175
+ return foldReport(state, event, time);
2176
+ case TYPE_SUBAGENT_START:
2177
+ return foldSubagent(state, event, time, seq, "start");
2178
+ case TYPE_SUBAGENT_END:
2179
+ return foldSubagent(state, event, time, seq, "end");
2180
+ case TYPE_WORKFLOW:
2181
+ case TYPE_WORKFLOW_START:
2182
+ case TYPE_WORKFLOW_PHASE:
2183
+ case TYPE_WORKFLOW_END:
2184
+ return foldWorkflowBus(state, type, event, time, seq);
2185
+ case TYPE_TW_RUN_START:
2186
+ case TYPE_TW_RUN_END:
2187
+ case TYPE_TW_AGENT_START:
2188
+ case TYPE_TW_AGENT_END:
2189
+ return foldWorkflowRun(state, type, event, time, seq);
2190
+ case TYPE_JOB_REGISTERED:
2191
+ return foldJob(state, event, time, seq, false);
2192
+ case TYPE_JOB_SETTLED:
2193
+ return foldJob(state, event, time, seq, true);
2194
+ case TYPE_USER_QUESTIONS:
2195
+ return foldQuestion(state, event, time, seq);
2196
+ case TYPE_APPROVAL:
2197
+ return foldApproval(state, event, time, seq);
2198
+ case TYPE_INBOX_SPLICED:
2199
+ return foldInboxSpliced(state, event, time);
2200
+ case TYPE_INBOX_DISCARDED:
2201
+ return foldInboxDiscarded(state, event, time);
2202
+ case TYPE_APPROVAL_ASKED:
2203
+ return foldApprovalAsked(state, event, time, seq);
2204
+ case TYPE_APPROVAL_DECIDED:
2205
+ return foldApprovalDecided(state, event, time);
2206
+ default:
2207
+ return state;
2208
+ }
2209
+ }
2210
+
2211
+ // ---------------------------------------------------------------------------
2212
+ // view
2213
+ // ---------------------------------------------------------------------------
2214
+
2215
+ /** Node as the client sees it (bounded, lossless JSON). */
2216
+ function nodeView(node) {
2217
+ return {
2218
+ id: node.id,
2219
+ stepIndex: node.stepIndex,
2220
+ kind: node.kind,
2221
+ tool: node.tool,
2222
+ title: node.title,
2223
+ detail: node.detail,
2224
+ argsText: node.argsText,
2225
+ files: node.files.map((file) => ({ path: file.path, action: file.action })),
2226
+ resultHead: node.resultHead,
2227
+ error: node.error,
2228
+ at: node.at,
2229
+ durationMs: node.durationMs,
2230
+ // the detail's meta line names the event that produced the node. Null for the
2231
+ // synthetic nodes (a message without a seq), never invented.
2232
+ seq: node.seq === null || node.seq === undefined ? null : node.seq,
2233
+ };
2234
+ }
2235
+
2236
+ /** Step as the client sees it. */
2237
+ function stepView(step, state, now) {
2238
+ return {
2239
+ index: step.index,
2240
+ planIndex: step.planIndex ?? null,
2241
+ title: step.title,
2242
+ status: step.status,
2243
+ source: step.source,
2244
+ outcome: step.outcome ?? "",
2245
+ startedAt: step.startedAt,
2246
+ endedAt: step.endedAt,
2247
+ lastActivityAt: step.lastActivityAt ?? 0,
2248
+ modelStep: step.modelStep ?? null,
2249
+ anchorSeq: step.anchorSeq ?? null,
2250
+ percent: step.percent,
2251
+ // when the LAST number arrived (0 = never). Lets the rail show how stale the
2252
+ // estimate it is quoting actually is, without reading a clock anywhere.
2253
+ lastEstimateAt: step.lastEstimateAt ?? 0,
2254
+ percentSource: step.percentSource,
2255
+ maxPercent: step.maxPercent,
2256
+ reportSummary: step.reportSummary,
2257
+ nodeCount: step.nodeCount,
2258
+ nodeIds: [...step.nodeIds],
2259
+ truncatedNodes: step.truncatedNodes,
2260
+ groups: step.groups.map((group) => ({ ...group })),
2261
+ // derived in buildView: a step that is waiting on a human right now. The plan's own
2262
+ // `requiresConfirmation` is merged by the host (lib/index.js); this adds the LOG-derived
2263
+ // half — an open approval or an unanswered question belonging to this step.
2264
+ pendingConfirm: Array.isArray(step.pendingConfirm) ? step.pendingConfirm.slice() : [],
2265
+ };
2266
+ }
2267
+
2268
+ /**
2269
+ * The wire view. Everything the client draws comes from here; nothing else is published.
2270
+ * Built at a caller-supplied `now` (the host passes `state.asOf`), so no clock read.
2271
+ */
2272
+ /** One activity row of a branch lane, as the client sees it. */
2273
+ function branchNodeView(node) {
2274
+ return {
2275
+ id: node.id,
2276
+ kind: node.kind,
2277
+ tool: node.tool || "",
2278
+ title: node.title,
2279
+ detail: node.detail || "",
2280
+ at: node.at,
2281
+ status: node.status,
2282
+ key: node.key || "",
2283
+ };
2284
+ }
2285
+
2286
+ /** Branch lane as the client sees it (bounded text, lossless JSON). */
2287
+ function branchView(branch) {
2288
+ return {
2289
+ id: branch.id,
2290
+ kind: branch.kind,
2291
+ label: branch.label,
2292
+ status: branch.status,
2293
+ startedAt: branch.startedAt,
2294
+ endedAt: branch.endedAt,
2295
+ stepIndex: branch.stepIndex,
2296
+ // Identity of the thing that opened the lane: the launcher's callId, the child's run id
2297
+ // and the job id. Short strings, and the only way a reader (or a checker) can tie a lane
2298
+ // back to the raw event that produced it.
2299
+ callId: branch.callId,
2300
+ runId: branch.runId,
2301
+ jobId: branch.jobId,
2302
+ count: branch.count,
2303
+ nodes: branch.nodes.map(branchNodeView),
2304
+ truncated: Math.max(0, branch.count - branch.nodes.length),
2305
+ };
2306
+ }
2307
+
2308
+ // ---------------------------------------------------------------------------
2309
+ // "strict sync with the conversation page" and needs-confirmation - both derived from the log
2310
+ // ---------------------------------------------------------------------------
2311
+ /**
2312
+ * "strict sync with the conversation page" - the todo table is the ONE published step list.
2313
+ *
2314
+ * Why this exists: the fold ADDS a step row the first time a `todo/write` carries it, and never
2315
+ * removes it. That is right for history but wrong for the panel, which must match what the
2316
+ * conversation's task list says RIGHT NOW. Measured on 129 real logs: 97 sessions ended with
2317
+ * rows that no longer appear in the latest table (re-plans and abandoned lists), so the map
2318
+ * showed extra "done/pending" steps the user had already replaced. Keeping historical rows also
2319
+ * inflated the header's denominator.
2320
+ *
2321
+ * Rules (all of them are facts, none is a guess):
2322
+ * • a row the latest table still names — keep, whatever its status;
2323
+ * • a lone synthetic planning row (planIndex undefined, title planning & research) — keep only while
2324
+ * nothing else exists, i.e. the true planning phase;
2325
+ * • everything else — drop. A PAE row is NOT protected: PAE rewrites the whole table after
2326
+ * every step, so a row it no longer lists is not part of the plan any more.
2327
+ * Nodes of a dropped row go with it (a step nobody lists cannot own work), and `active` is
2328
+ * re-pointed at the surviving running row so no highlight ever dangles.
2329
+ */
2330
+ function syncToTodos(state) {
2331
+ if (state.todos.length === 0) return state;
2332
+ // The todo table's OWN order is the order the conversation shows. Build the target list from
2333
+ // it, then pull each step record out of the fold's history by title. A row the fold has never
2334
+ // seen becomes a fresh pending row (the next `todo/write` will fill it in), which is exactly
2335
+ // what the conversation shows at that moment.
2336
+ const wanted = [];
2337
+ const seen = new Set();
2338
+ for (const item of state.todos) {
2339
+ if (item.content === "") continue;
2340
+ const title = text(stripOrdinal(item.content), TITLE_LIMIT);
2341
+ const key = normalizeTitle(item.content);
2342
+ if (key === "" || seen.has(key)) continue;
2343
+ seen.add(key);
2344
+ wanted.push({ key, title, status: item.status });
2345
+ }
2346
+ if (wanted.length === 0) return state;
2347
+
2348
+ const byKey = new Map();
2349
+ for (const step of state.steps) {
2350
+ const key = normalizeTitle(step.title);
2351
+ if (key !== "" && !byKey.has(key)) byKey.set(key, step);
2352
+ }
2353
+ // REWORDED ROWS. The model rewrites its own todo text between `todo/write` events
2354
+ // (measured on this session: "read-only inventory: read plugin sources..." became "read-only inventory: sources/config..."), and the
2355
+ // fold had already created a row for the old wording. Without a second pass those old rows stay
2356
+ // behind as orphans AND the new row starts with no nodes — which is exactly why "only the running
2357
+ // step shows tool types". A reworded row is matched by the longest shared prefix of its
2358
+ // normalised title (≥ REWORD_PREFIX_MIN characters) among the steps nothing else claimed.
2359
+ // A row is "claimed" when the CURRENT table names it verbatim. `claimed` must be built from the
2360
+ // wanted keys, NOT by looking the row back up in `byKey`: `byKey` is keyed BY those same rows, so
2361
+ // that reverse lookup marked every row as claimed, left `orphans` permanently empty, and made the
2362
+ // reworded-row branch below unreachable — its nodes were then dropped as orphans.
2363
+ const wantedKeys = new Set(wanted.map((row) => row.key));
2364
+ const claimed = new Set();
2365
+ for (const step of state.steps) if (wantedKeys.has(normalizeTitle(step.title))) claimed.add(step.index);
2366
+ const orphans = state.steps.filter((step) => !claimed.has(step.index));
2367
+ const used = new Set();
2368
+ // old row key -> new row key, for every row this pass reworded. The node remap below
2369
+ // walks through it, so a reworded row keeps its ledger instead of dropping it.
2370
+ const reworded = new Map();
2371
+ // Each entry remembers WHICH old row it came from (`from`), because the reworded case returns a
2372
+ // NEW object with a new title: comparing objects with indexOf later would then fail to find the
2373
+ // old row and drop its nodes (that bug emptied every finished step's node list).
2374
+ const ordered = [];
2375
+ const origins = [];
2376
+ for (const row of wanted) {
2377
+ const exact = byKey.get(row.key);
2378
+ if (exact) {
2379
+ ordered.push(exact);
2380
+ origins.push(exact.index);
2381
+ continue;
2382
+ }
2383
+ // REWORD DETECTION, two signals in priority order.
2384
+ //
2385
+ // (1) POSITIONAL STATE MATCH. The todo table is an ordered list and the model rewrites its text
2386
+ // in place: same slot, same lifecycle status, different wording. Measured on this session,
2387
+ // real reworded pairs share as little as 5 of 8 normalised characters, so a text-only
2388
+ // threshold either misses them (nodes lost) or glues unrelated rows together. Slot + status
2389
+ // is the signal that actually holds.
2390
+ // (2) TEXT SHARE as the fallback for a re-numbered or re-ordered table.
2391
+ let candidate = null;
2392
+ const slot = wanted.indexOf(row);
2393
+ const sameSlot = orphans.find((step) => !used.has(step.index) && step.index === slot);
2394
+ if (sameSlot && (sameSlot.status === row.status || sameSlot.status === STEP_STATUS.running)) {
2395
+ candidate = sameSlot;
2396
+ }
2397
+ if (candidate === null) {
2398
+ let bestShare = 0;
2399
+ for (const step of orphans) {
2400
+ if (used.has(step.index)) continue;
2401
+ const other = normalizeTitle(step.title);
2402
+ const score = commonPrefix(other, row.key);
2403
+ if (score < REWORD_PREFIX_MIN) continue;
2404
+ const shorter = Math.min(other.length, row.key.length);
2405
+ const share = shorter === 0 ? 0 : score / shorter;
2406
+ if (share > bestShare) {
2407
+ bestShare = share;
2408
+ candidate = step;
2409
+ }
2410
+ }
2411
+ if (bestShare < REWORD_SHARE_MIN) candidate = null;
2412
+ }
2413
+ if (candidate !== null) {
2414
+ used.add(candidate.index);
2415
+ // When a title is reworded the row identity (stepKey) follows, and every node already attached to that row is migrated.
2416
+ // This is the real root cause of "a finished step shows 0 tools": the nodes' stepKey stayed at the old title, while the new row key was the
2417
+ // reworded title - the two did not match, so the nodes were dropped as orphans in the view.
2418
+ const oldKey = candidate.stepKey || normalizeTitle(candidate.title);
2419
+ const newKey = normalizeTitle(row.title);
2420
+ const nextStep = { ...candidate, title: text(row.title, TITLE_LIMIT), stepKey: newKey };
2421
+ reworded.set(oldKey, newKey);
2422
+ ordered.push(nextStep);
2423
+ origins.push(candidate.index);
2424
+ continue;
2425
+ }
2426
+ // No candidate: this is a genuinely new row.
2427
+ const created = newStep(ordered.length, row.title, 0, "todo");
2428
+ // the row's STABLE key, the SAME expression appendNode uses
2429
+ // (`normalizeTitle(step.title)`). Without it a created row has no key, so neither the
2430
+ // node-remap nor the node attribution can ever find it.
2431
+ created.stepKey = row.key;
2432
+ created.startedAt = 0;
2433
+ created.planIndex = planIndexOf(row.title) ?? undefined;
2434
+ created.status = row.status === "completed" ? STEP_STATUS.done : STEP_STATUS.pending;
2435
+ if (created.status === STEP_STATUS.done) {
2436
+ created.percent = 100;
2437
+ created.percentSource = "closed";
2438
+ created.maxPercent = 100;
2439
+ }
2440
+ ordered.push(created);
2441
+ origins.push(-1);
2442
+ }
2443
+ // A node follows its ROW, identified by the row's stable key — not by a position and not by
2444
+ // object identity (the reworded case builds a new object). Rows the table no longer names are
2445
+ // gone, and their nodes go with them.
2446
+ const steps = reindex(ordered);
2447
+ const newIndexByKey = new Map();
2448
+ for (const step of steps) {
2449
+ const key = step.stepKey || normalizeTitle(step.title);
2450
+ if (key !== "" && !newIndexByKey.has(key)) newIndexByKey.set(key, step.index);
2451
+ }
2452
+ // Old key -> current index, for rows foldTodos renamed in place (`step.prevKey`). Used below when
2453
+ // a node still carries the pre-rename key.
2454
+ const prevKeyIndex = new Map();
2455
+ for (const step of steps) {
2456
+ const aliases = Array.isArray(step.aliasKeys) ? step.aliasKeys : [];
2457
+ for (const alias of aliases) {
2458
+ const prev = normalizeTitle(typeof alias === "string" ? alias : "");
2459
+ if (prev !== "") prevKeyIndex.set(prev, step.index);
2460
+ }
2461
+ const prev = normalizeTitle(typeof step.prevKey === "string" ? step.prevKey : "");
2462
+ if (prev !== "") prevKeyIndex.set(prev, step.index);
2463
+ }
2464
+ const sameOrder = ordered.every((step, at) => step.index === at && origins[at] === at);
2465
+ // Rows this pass let go: old indices no surviving row claims. A kept row (exact or reworded)
2466
+ // carries its old index in `origins`, a created row carries -1, so the unclaimed old indices
2467
+ // are exactly the dropped set — the identity fast path below needs it to know the row set is
2468
+ // unchanged.
2469
+ const keptOld = new Set(origins.filter((from) => from >= 0));
2470
+ const dropped = new Set(state.steps.filter((step) => !keptOld.has(step.index)));
2471
+ // Old position -> new position, for nodes that carry no `stepKey`.
2472
+ const movedByIndex = new Map();
2473
+ origins.forEach((from, at) => {
2474
+ if (from >= 0) movedByIndex.set(from, at);
2475
+ });
2476
+ const nodes = state.nodes
2477
+ .map((node) => {
2478
+ const raw = node.stepKey || null;
2479
+ if (raw === null || raw === "") {
2480
+ // A node folded before `stepKey` existed (or by a path that did not set one) keeps its old
2481
+ // POSITION, so it must be remapped by that position — leaving it untouched would make it
2482
+ // point at whatever row now sits at that index.
2483
+ const at = movedByIndex.get(node.stepIndex);
2484
+ if (at === undefined) return null;
2485
+ return at === node.stepIndex ? node : { ...node, stepIndex: at };
2486
+ }
2487
+ // A reworded row renamed its key: follow the rename before looking the index up, and keep
2488
+ // the node's own stepKey in sync so the NEXT pass does not have to re-derive it.
2489
+ const key = reworded.get(raw) || raw;
2490
+ const at = newIndexByKey.get(key);
2491
+ if (at === undefined) {
2492
+ // foldTodos renames a row in place (title + stepKey) and parks the old key in
2493
+ // `step.prevKey`. A node still carrying that old key belongs to the renamed row — this is
2494
+ // the path that keeps a finished step's tool/file types after the model rewrites its text.
2495
+ const renamed = prevKeyIndex.get(raw);
2496
+ if (renamed === undefined) return null;
2497
+ return { ...node, stepIndex: renamed, stepKey: normalizeTitle(steps[renamed].title) };
2498
+ }
2499
+ if (node.stepIndex === at && node.stepKey === key) return node;
2500
+ return { ...node, stepIndex: at, stepKey: key };
2501
+ })
2502
+ .filter((node) => node !== null);
2503
+ if (sameOrder && dropped.size === 0 && nodes.length === state.nodes.length && ordered.length === state.steps.length) {
2504
+ return state;
2505
+ }
2506
+ const oldActiveKey = state.active
2507
+ ? (state.steps[state.active.stepIndex] &&
2508
+ (state.steps[state.active.stepIndex].stepKey || normalizeTitle(state.steps[state.active.stepIndex].title))) ||
2509
+ null
2510
+ : null;
2511
+ const activeNew = oldActiveKey === null ? undefined : newIndexByKey.get(oldActiveKey);
2512
+ const active =
2513
+ activeNew === undefined
2514
+ ? (() => {
2515
+ const running = runningStep({ steps });
2516
+ return running ? { stepIndex: running.index, nodeId: null } : null;
2517
+ })()
2518
+ : { stepIndex: activeNew, nodeId: state.active.nodeId };
2519
+ return { ...state, steps, nodes, active };
2520
+ }
2521
+
2522
+ /**
2523
+ * `{ stepIndex: [{ kind, label, at }] }` for every step that is waiting on a human.
2524
+ *
2525
+ * The three MEASURED sources, in order:
2526
+ * 1. `approval/asked` with no `approval/decided` for the same id — dsh-user-approval appends
2527
+ * the pair inside one turn (its invariant.js refuses a lone `decided`), so an ask with
2528
+ * nothing after it is a decision still open at the end of the log.
2529
+ * 2. a `question` node whose `ask_user_question` call never produced an answer — its
2530
+ * `tool/result` records the pending notice instead of an answer batch
2531
+ * (dsh-tool-ask-user's `pendingNotice`).
2532
+ * 3. an `approval` node whose tool result never arrived.
2533
+ *
2534
+ * A node whose call was answered settles; a node with no call at all (the `approval/request`
2535
+ * bus shape, which a recorded log never carries) stays open until the log ends.
2536
+ */
2537
+ function pendingConfirmSteps(state) {
2538
+ const out = Object.create(null);
2539
+ const add = (index, row) => {
2540
+ if (!Number.isInteger(index) || index < 0) return;
2541
+ if (!out[index]) out[index] = [];
2542
+ out[index].push(row);
2543
+ };
2544
+
2545
+ // 1. open approvals from the audit pair, by the approval's own step attribution.
2546
+ const asked = new Map();
2547
+ if (Array.isArray(state.approvals)) {
2548
+ for (const row of state.approvals) {
2549
+ if (!isRecord(row)) continue;
2550
+ // Corrects an implementation that contradicted its own comment. `foldApprovalDecided` keeps rejected/cancelled rows in approvals
2551
+ // marked state:'decided' (only an actual allowed-once removes the row), because "rejected" is still
2552
+ // a decision the user has to handle - the host will ask again. The old code treated any 'decided' as finished, so
2553
+ // a rejected approval stopped showing needs-confirmation. Now: asked and "decided but not allowed" both count as unresolved.
2554
+ if (row.state === "asked") asked.set(row.id, row);
2555
+ else if (row.state === "decided" && row.outcome !== "allowed-once") asked.set(row.id, row);
2556
+ else if (row.state === "decided") asked.delete(row.id);
2557
+ }
2558
+ }
2559
+ for (const row of asked.values()) add(row.stepIndex, { kind: "approval", label: row.label, at: row.at });
2560
+ // 2./3. question / approval nodes with no settling tool result.
2561
+ if (Array.isArray(state.nodes)) {
2562
+ for (const node of state.nodes) {
2563
+ if (node.kind !== "question" && node.kind !== "approval") continue;
2564
+ // An answered question is no longer waiting on anyone.
2565
+ if (node.answered === true) continue;
2566
+ add(node.stepIndex, { kind: node.kind, label: node.title, at: node.at });
2567
+ }
2568
+ }
2569
+ return out;
2570
+ }
2571
+
2572
+ export function buildView(state, nowMs) {
2573
+ const source = isRecord(state) ? state : createInitialState();
2574
+ const now = isFiniteNumber(nowMs) && nowMs > 0 ? nowMs : source.asOf;
2575
+ // the view is built from the step list the CONVERSATION currently shows: syncToTodos
2576
+ // drops rows the latest todo table no longer names (measured: 97 of 129 real logs carried such
2577
+ // rows), so the map can no longer display steps the task list has already replaced.
2578
+ const live = withPercents(withGroups(soleRunning(syncToTodos(source), now)), now);
2579
+ const steps = live.steps.map((step) => stepView(step, live, now));
2580
+ const nodeIds = new Set();
2581
+ for (const step of steps) for (const id of step.nodeIds) nodeIds.add(id);
2582
+ const nodes = live.nodes.filter((node) => nodeIds.has(node.id)).map(nodeView);
2583
+ const active = live.active;
2584
+ const activeStep = active && steps[active.stepIndex] ? steps[active.stepIndex] : null;
2585
+ const activeNode = active && active.nodeId ? nodes.find((node) => node.id === active.nodeId) : null;
2586
+
2587
+ // NEEDS-CONFIRMATION. Two halves, both derived, neither guessed:
2588
+ // 1. the session's own shapes — an `approval/asked` without its `approval/decided`, and a
2589
+ // `question` / `approval` node with no later outcome in the same step (the tool result
2590
+ // that settles it). Both are MEASURED log facts (dsh-user-approval appends the pair;
2591
+ // dsh-tool-ask-user records the pending result).
2592
+ // 2. the PAE plan's own `requiresConfirmation` — merged onto `step.pendingConfirm` by
2593
+ // the host half (lib/index.js), exactly like the status merge.
2594
+ // A step that is DONE never carries the flag: a decision a finished step was waiting on
2595
+ // is history, not a request.
2596
+ const decided = pendingConfirmSteps(live);
2597
+ for (const step of steps) {
2598
+ if (step.status === STEP_STATUS.done || step.status === STEP_STATUS.failed) continue;
2599
+ const rows = decided[step.index];
2600
+ if (Array.isArray(rows) && rows.length > 0) step.pendingConfirm = rows;
2601
+ }
2602
+
2603
+
2604
+
2605
+ return {
2606
+ ver: SCHEMA_VERSION,
2607
+ task: source.task,
2608
+ todos: source.todos.map((item) => ({ content: item.content, status: item.status })),
2609
+ steps,
2610
+ nodes,
2611
+ // Branch lanes are NOT part of `nodes`: the main flow's node count, its same-tool
2612
+ // groups and both caps ignore them, and only a lane's own status dot breathes
2613
+ // (§4: branches never compete for the “current activity” highlight).
2614
+ branches: live.branches.map(branchView),
2615
+ active: activeStep ? { stepIndex: activeStep.index, nodeId: activeNode ? activeNode.id : null } : null,
2616
+ activeNode: activeNode ? nodeView(activeNode) : null,
2617
+ asOf: live.asOf,
2618
+ // is the session working right now? `turn/start` opened a turn and nothing closed
2619
+ // it. The browser half pauses every animation when this is false; the step rows cannot
2620
+ // answer it (PAE leaves them 'running' after the turn ends).
2621
+ running: live.turnActive === true,
2622
+ truncated: { ...live.truncated },
2623
+ // the hot-inject history: what THIS plugin's /steer channel put into the session, and
2624
+ // whether the session has admitted each one. The browser half renders it under the composer.
2625
+ injects: source.injects.map((row) => ({ ...row })),
2626
+ counts: {
2627
+ steps: steps.length,
2628
+ nodes: nodes.length,
2629
+ running: steps.filter((step) => step.status === STEP_STATUS.running).length,
2630
+ completed: steps.filter((step) => step.status === STEP_STATUS.done).length,
2631
+ },
2632
+ };
2633
+ }