dsh-workflow-map 0.0.0-stage → 0.3.4

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