pi-durable-subagents 1.0.7 → 1.0.9

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.
@@ -67,17 +67,16 @@ function callKey(call) {
67
67
  export function refusedResult(key, reason) {
68
68
  return { key, gen: 0, status: "failed", ok: false, error: reason === "spawn-budget" ? "spawn budget exceeded" : String(reason), output: "" };
69
69
  }
70
- export function snapshotFromEntries(wid, entries) {
70
+ function snapshotReducer(wid, entries) {
71
71
  const created = entries.find(e => e.type === "wf-created");
72
72
  const rev = Math.max(1, ...entries.filter(e => e.type === "wf-created" || e.type === "revised").map(e => Number(e.revision) || 1));
73
73
  const boundary = entries.findLastIndex(e => e.type === "revised");
74
74
  const current = entries.slice(Math.max(0, boundary));
75
- const terminal = current.findLast(e => e.type === JT.done || (e.type === "resumed" && !e.call));
76
- const done = terminal?.type === JT.done ? terminal : undefined;
75
+ let terminal = current.findLast(e => e.type === JT.done || (e.type === "resumed" && !e.call));
77
76
  const calls = new Map();
78
77
  const byExec = new Map();
79
78
  const hibernating = new Map(); // callId → exec that decided to hibernate and is not fenced yet
80
- const resolved = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}@${e.rev}`));
79
+ const resolved = new Set();
81
80
  const generations = new Set(), retired = new Set();
82
81
  const attention = [];
83
82
  // P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
@@ -85,191 +84,285 @@ export function snapshotFromEntries(wid, entries) {
85
84
  const tools = new Map();
86
85
  // P7, P27: forward / forward-delivered / forward-retired, by destination call; retirements carry only rid2.
87
86
  const sends = new Map(), byRid2 = new Map();
88
- for (const e of entries) {
89
- if (["call", "generation", "refused", "reused"].includes(e.type)) {
90
- if (boundary >= 0 && e.seq < entries[boundary].seq)
91
- continue;
92
- const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
93
- const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
94
- if (e.type === "generation")
95
- generations.add(callId);
96
- const result = e.type === "refused" ? refusedResult(key, e.reason) :
97
- e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
98
- const wanted = e.spec?.model;
99
- calls.set(callId, { key, gen, callId, pos: Number(e.pos), agent: String(e.spec?.agent ?? ""),
100
- // Until a slot is acquired (`selected`), show the model the call asked for, not nothing.
101
- ...(typeof wanted === "string" && wanted ? { model: wanted } : {}),
102
- phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
103
- ...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
104
- }
105
- else if (e.type === "retired") {
106
- retired.add(String(e.call));
107
- }
108
- else if (e.type === JT.exec) {
109
- const call = calls.get(String(e.call));
110
- if (!call)
111
- continue;
112
- // An execution waits for a provider slot and memory before it launches; it is running once `selected` says so
113
- // (otherwise a call still waiting for a slot reads "thinking · 2m").
114
- call.exec = String(e.exec);
115
- call.phase = "queued";
116
- call.startedAt ??= e.ts;
117
- call.lastActivity = e.ts;
118
- byExec.set(call.exec, call);
119
- delete call.hibernated;
120
- hibernating.delete(call.callId);
121
- }
122
- else if (e.type === "hibernated") {
123
- // The decision to hibernate precedes the fence; the slot is released only once the execution is fenced.
124
- const call = calls.get(String(e.call));
125
- if (call?.exec && call.exec === e.exec)
126
- hibernating.set(call.callId, call.exec);
127
- }
128
- else if (e.type === "answer-bound" || (e.type === "resumed" && e.call)) {
129
- const call = calls.get(String(e.call));
130
- if (call) {
131
- delete call.hibernated;
132
- hibernating.delete(call.callId);
87
+ function apply(batch, from = 0) {
88
+ for (let i = from; i < batch.length; i++) {
89
+ const e = batch[i];
90
+ if (i >= boundary && (e.type === JT.done || (e.type === "resumed" && !e.call)))
91
+ terminal = e;
92
+ if (e.type === JT.attentionResolved)
93
+ resolved.add(`${e.id}@${e.rev}`);
94
+ if (["call", "generation", "refused", "reused"].includes(e.type)) {
95
+ if (boundary >= 0 && e.seq < entries[boundary].seq)
96
+ continue;
97
+ const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
98
+ const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
99
+ if (e.type === "generation")
100
+ generations.add(callId);
101
+ const result = e.type === "refused" ? refusedResult(key, e.reason) :
102
+ e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
103
+ const wanted = e.spec?.model;
104
+ calls.set(callId, { key, gen, callId, pos: Number(e.pos), agent: String(e.spec?.agent ?? ""),
105
+ // Until a slot is acquired (`selected`), show the model the call asked for, not nothing.
106
+ ...(typeof wanted === "string" && wanted ? { model: wanted } : {}),
107
+ phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
108
+ ...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
133
109
  }
134
- }
135
- else if (e.type === JT.fenced) {
136
- // A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
137
- const call = byExec.get(String(e.exec));
138
- if (call && call.phase === "running")
110
+ else if (e.type === "retired") {
111
+ retired.add(String(e.call));
112
+ }
113
+ else if (e.type === JT.exec) {
114
+ const call = calls.get(String(e.call));
115
+ if (!call)
116
+ continue;
117
+ // An execution waits for a provider slot and memory before it launches; it is running once `selected` says so
118
+ // (otherwise a call still waiting for a slot reads "thinking · 2m").
119
+ call.exec = String(e.exec);
139
120
  call.phase = "queued";
140
- if (call && hibernating.get(call.callId) === e.exec)
141
- call.hibernated = true;
142
- }
143
- else if (e.type === "selected") {
144
- const call = byExec.get(String(e.exec)), m = e.model;
145
- if (call && m)
146
- call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
147
- if (call && call.phase === "queued")
148
- call.phase = "running";
149
- }
150
- else if (e.type === "observation") {
151
- // Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
152
- const call = byExec.get(String(e.exec));
153
- if (call)
121
+ call.startedAt ??= e.ts;
154
122
  call.lastActivity = e.ts;
155
- if (call && e.type === "observation" && e.event?.type === "tool_execution_start")
156
- tools.set(call.callId, (tools.get(call.callId) ?? 0) + 1);
157
- }
158
- else if (e.type === "usage") {
159
- const id = `${e.call}:${e.id}`, u = e.usage;
160
- if (seen.has(id) || !u)
161
- continue;
162
- seen.add(id);
163
- const total = live.get(String(e.call)) ?? zero();
164
- total.input += u.input;
165
- total.output += u.output;
166
- total.costUsd += u.costUsd;
167
- live.set(String(e.call), total);
168
- }
169
- else if (e.type === JT.sealed) {
170
- const usage = e.result?.usage;
171
- if (usage)
172
- sealedUsage.set(String(e.call), usage);
173
- const call = calls.get(String(e.call));
174
- if (!call)
175
- continue;
176
- call.phase = "sealed";
177
- call.result = e.result;
178
- call.endedAt = e.ts;
179
- delete call.hibernated;
180
- hibernating.delete(call.callId);
181
- }
182
- else if (e.type === JT.attention) {
183
- const item = e.item;
184
- if (!resolved.has(`${item.id}@${item.rev}`))
123
+ byExec.set(call.exec, call);
124
+ delete call.hibernated;
125
+ hibernating.delete(call.callId);
126
+ }
127
+ else if (e.type === "hibernated") {
128
+ // The decision to hibernate precedes the fence; the slot is released only once the execution is fenced.
129
+ const call = calls.get(String(e.call));
130
+ if (call?.exec && call.exec === e.exec)
131
+ hibernating.set(call.callId, call.exec);
132
+ }
133
+ else if (e.type === "answer-bound" || (e.type === "resumed" && e.call)) {
134
+ const call = calls.get(String(e.call));
135
+ if (call) {
136
+ delete call.hibernated;
137
+ hibernating.delete(call.callId);
138
+ }
139
+ }
140
+ else if (e.type === JT.fenced) {
141
+ // A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
142
+ const call = byExec.get(String(e.exec));
143
+ if (call && call.phase === "running")
144
+ call.phase = "queued";
145
+ if (call && hibernating.get(call.callId) === e.exec)
146
+ call.hibernated = true;
147
+ }
148
+ else if (e.type === "selected") {
149
+ const call = byExec.get(String(e.exec)), m = e.model;
150
+ if (call && m)
151
+ call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
152
+ if (call && call.phase === "queued")
153
+ call.phase = "running";
154
+ }
155
+ else if (e.type === "model-used") {
156
+ // Evidence of a switch: the execution answered with another model than it launched with.
157
+ const call = byExec.get(String(e.exec)), m = e.model;
158
+ if (call && m)
159
+ call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
160
+ }
161
+ else if (e.type === "observation") {
162
+ // Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
163
+ const call = byExec.get(String(e.exec));
164
+ if (call)
165
+ call.lastActivity = e.ts;
166
+ if (call && e.type === "observation" && e.event?.type === "tool_execution_start")
167
+ tools.set(call.callId, (tools.get(call.callId) ?? 0) + 1);
168
+ }
169
+ else if (e.type === "usage") {
170
+ const id = `${e.call}:${e.id}`, u = e.usage;
171
+ if (seen.has(id) || !u)
172
+ continue;
173
+ seen.add(id);
174
+ const total = live.get(String(e.call)) ?? zero();
175
+ total.input += u.input;
176
+ total.output += u.output;
177
+ total.costUsd += u.costUsd;
178
+ live.set(String(e.call), total);
179
+ }
180
+ else if (e.type === JT.sealed) {
181
+ const usage = e.result?.usage;
182
+ if (usage)
183
+ sealedUsage.set(String(e.call), usage);
184
+ const call = calls.get(String(e.call));
185
+ if (!call)
186
+ continue;
187
+ call.phase = "sealed";
188
+ call.result = e.result;
189
+ call.endedAt = e.ts;
190
+ delete call.hibernated;
191
+ hibernating.delete(call.callId);
192
+ }
193
+ else if (e.type === JT.attention) {
194
+ const item = e.item;
185
195
  attention.push(item);
196
+ }
197
+ else if (e.type === "forward") {
198
+ const envelope = e.envelope;
199
+ const send = { rid: String(e.rid), kind: String(envelope?.kind ?? ""), state: "pending", at: e.ts,
200
+ ...(envelope?.kind === "model" && envelope.body?.provider ? { model: `${envelope.body.provider}/${envelope.body.model}` } : {}) };
201
+ const list = sends.get(String(e.dest)) ?? [];
202
+ list.push(send);
203
+ sends.set(String(e.dest), list);
204
+ // A withdrawn model request no longer stands (the executor ignores it for the next launch as well).
205
+ if (envelope?.kind === "withdraw")
206
+ for (const rid2 of envelope.body?.rids ?? []) {
207
+ const target = byRid2.get(`${e.dest}\n${rid2}`);
208
+ if (target?.kind === "model" && target.state === "pending")
209
+ target.reason = "withdrawn";
210
+ }
211
+ byRid2.set(`${e.dest}\n${e.rid2}`, send);
212
+ byRid2.set(String(e.rid2), send);
213
+ }
214
+ else if (e.type === "forward-delivered" || e.type === "forward-retired") {
215
+ const send = byRid2.get(e.type === "forward-delivered" ? `${e.call}\n${e.rid2}` : String(e.rid2));
216
+ if (!send || send.state !== "pending")
217
+ continue;
218
+ send.state = e.type === "forward-delivered" ? "delivered" : "retired";
219
+ send.at = e.ts;
220
+ if (e.type === "forward-delivered" && e.reason !== undefined)
221
+ send.reason = String(e.reason);
222
+ }
186
223
  }
187
- else if (e.type === "forward") {
188
- const envelope = e.envelope;
189
- const send = { rid: String(e.rid), kind: String(envelope?.kind ?? ""), state: "pending", at: e.ts,
190
- ...(envelope?.kind === "model" && envelope.body?.provider ? { model: `${envelope.body.provider}/${envelope.body.model}` } : {}) };
191
- const list = sends.get(String(e.dest)) ?? [];
192
- list.push(send);
193
- sends.set(String(e.dest), list);
194
- byRid2.set(`${e.dest}\n${e.rid2}`, send);
195
- byRid2.set(String(e.rid2), send);
224
+ }
225
+ apply(entries);
226
+ function finish() {
227
+ const done = terminal?.type === JT.done ? terminal : undefined;
228
+ // Finish owns every exposed object; UI decoration and consumers cannot alter reducer history.
229
+ const list = structuredClone([...calls.values()]);
230
+ const openAttention = structuredClone(attention.filter(item => !resolved.has(`${item.id}@${item.rev}`)));
231
+ for (const item of openAttention) {
232
+ const call = item.kind === "question" ? list.find(c => item.id.startsWith(`q:${c.callId}:`)) : undefined;
233
+ if (call && (call.phase === "running" || call.phase === "queued"))
234
+ call.phase = "asking"; // a hibernated asker is fenced
196
235
  }
197
- else if (e.type === "forward-delivered" || e.type === "forward-retired") {
198
- const send = byRid2.get(e.type === "forward-delivered" ? `${e.call}\n${e.rid2}` : String(e.rid2));
199
- if (!send || send.state !== "pending")
200
- continue;
201
- send.state = e.type === "forward-delivered" ? "delivered" : "retired";
202
- send.at = e.ts;
203
- if (e.type === "forward-delivered" && e.reason !== undefined)
204
- send.reason = String(e.reason);
236
+ for (const c of list)
237
+ if (c.hibernated && c.phase !== "asking")
238
+ delete c.hibernated;
239
+ const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
240
+ const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
241
+ for (const c of list) {
242
+ counts[c.phase]++;
243
+ const usage = usageOf(c.callId);
244
+ if (usage)
245
+ c.usage = { ...usage };
246
+ const n = tools.get(c.callId);
247
+ if (n)
248
+ c.tools = n;
249
+ const forwarded = sends.get(c.callId);
250
+ if (forwarded) {
251
+ c.sends = structuredClone(forwarded);
252
+ const pending = forwarded.filter(pendingMessage).length;
253
+ if (pending)
254
+ c.pending = pending;
255
+ const last = c.phase !== "sealed" && !retired.has(c.callId) ? forwarded.findLast(s => s.kind === "model" && s.reason !== "withdrawn") : undefined;
256
+ if (last?.reason !== undefined)
257
+ c.switchFailed = `${last.model} (${last.reason})`;
258
+ else if (last && last.state !== "retired" && last.model !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
259
+ c.switching = last.model;
260
+ }
205
261
  }
206
- }
207
- for (const item of attention) {
208
- const call = item.kind === "question" ? [...calls.values()].find(c => item.id.startsWith(`q:${c.callId}:`)) : undefined;
209
- if (call && (call.phase === "running" || call.phase === "queued"))
210
- call.phase = "asking"; // a hibernated asker is fenced
211
- }
212
- for (const c of calls.values())
213
- if (c.hibernated && c.phase !== "asking")
214
- delete c.hibernated;
215
- const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
216
- const list = [...calls.values()];
217
- const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
218
- for (const c of list) {
219
- counts[c.phase]++;
220
- const usage = usageOf(c.callId);
221
- if (usage)
222
- c.usage = { ...usage };
223
- const n = tools.get(c.callId);
224
- if (n)
225
- c.tools = n;
226
- const forwarded = sends.get(c.callId);
227
- if (forwarded) {
228
- c.sends = forwarded;
229
- const pending = forwarded.filter(pendingMessage).length;
230
- if (pending)
231
- c.pending = pending;
232
- const switching = c.phase !== "sealed" ? forwarded.findLast(s => s.kind === "model" && s.state === "pending")?.model : undefined;
233
- if (switching && switching !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
234
- c.switching = switching;
262
+ const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
263
+ for (const c of after)
264
+ c.afterEnd = true;
265
+ const followUps = after.length;
266
+ const usage = zero();
267
+ for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
268
+ const u = usageOf(id);
269
+ usage.input += u.input;
270
+ usage.output += u.output;
271
+ usage.costUsd += u.costUsd;
235
272
  }
273
+ return {
274
+ wid, rev, ...(typeof created?.name === "string" ? { name: created.name } : {}),
275
+ ...(created ? { origin: String(created.origin), cwd: String(created.cwd), startedAt: created.ts } : {}),
276
+ status: done ? done.status : "running",
277
+ ...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: structuredClone(done.result) } : {}),
278
+ ...(done ? { endedAt: done.ts } : {}),
279
+ ...(followUps ? { followUps } : {}),
280
+ calls: list, counts, attention: openAttention, usage,
281
+ };
236
282
  }
237
- const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
238
- for (const c of after)
239
- c.afterEnd = true;
240
- const followUps = after.length;
241
- const usage = zero();
242
- for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
243
- const u = usageOf(id);
244
- usage.input += u.input;
245
- usage.output += u.output;
246
- usage.costUsd += u.costUsd;
247
- }
248
- return {
249
- wid, rev, ...(typeof created?.name === "string" ? { name: created.name } : {}),
250
- ...(created ? { origin: String(created.origin), cwd: String(created.cwd), startedAt: created.ts } : {}),
251
- status: done ? done.status : "running",
252
- ...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
253
- ...(done ? { endedAt: done.ts } : {}),
254
- ...(followUps ? { followUps } : {}),
255
- calls: list, counts, attention, usage,
256
- };
283
+ // Only the current revision's reuse looks ahead for a seal (entries before the boundary are skipped).
284
+ return { apply, finish, hasReuse: current.some(e => e.type === "reused") };
285
+ }
286
+ export function snapshotFromEntries(wid, entries) {
287
+ return snapshotReducer(wid, entries).finish();
257
288
  }
258
289
  /** P25: Snapshot one workflow from its durable journal (v12 §4: plus the planned total of its pinned run body). */
259
290
  // A journal snapshot is immutable and replaced on every append, so its derived workflow snapshot is reused until then:
260
291
  // re-deriving every historical workflow on each UI refresh dominated pi's main thread.
292
+ const FOLDS = 256; // workflows whose fold state is kept; pruned and long-idle ones fall out
293
+ const folds = new Map();
261
294
  const derived = new WeakMap();
262
295
  export function workflowSnapshot(home, wid) {
263
- const entries = readJournalSnapshot(journalPath(home, wid)), hit = derived.get(entries);
264
- const wf = hit?.wid === wid ? hit.snapshot : snapshotFromEntries(wid, entries);
265
- if (hit?.wid !== wid && entries.length)
266
- derived.set(entries, { wid, snapshot: wf });
296
+ const path = journalPath(home, wid), entries = readJournalSnapshot(path), hit = derived.get(entries);
297
+ let wf;
298
+ if (hit?.wid === wid)
299
+ wf = hit.snapshot;
300
+ else {
301
+ const prior = folds.get(path);
302
+ // The journal reader preserves entry identity only when extending the same committed prefix.
303
+ const prefix = prior?.wid === wid && entries.length >= prior.length &&
304
+ (prior.length === 0 || entries[prior.length - 1] === prior.last);
305
+ let reusable = prefix;
306
+ if (reusable)
307
+ for (let i = prior.length; i < entries.length; i++) {
308
+ const e = entries[i];
309
+ // Revisions change earlier call identities/boundaries. Reuse looks ahead for the first seal.
310
+ if (e.type === "revised" || e.type === "wf-created" || e.type === "reused" ||
311
+ (prior.reducer.hasReuse && e.type === JT.sealed)) {
312
+ reusable = false;
313
+ break;
314
+ }
315
+ }
316
+ const reducer = reusable ? prior.reducer : snapshotReducer(wid, entries);
317
+ if (reusable)
318
+ reducer.apply(entries, prior.length);
319
+ wf = reducer.finish();
320
+ if (entries.length) {
321
+ folds.delete(path);
322
+ folds.set(path, { wid, length: entries.length, last: entries.at(-1), reducer }); // least recently folded first
323
+ if (folds.size > FOLDS)
324
+ folds.delete(folds.keys().next().value);
325
+ derived.set(entries, { wid, snapshot: wf });
326
+ }
327
+ else
328
+ folds.delete(path);
329
+ }
267
330
  const planned = plannedTotal(home, wid, wf.rev);
268
331
  return planned === undefined ? wf : { ...wf, planned };
269
332
  }
270
333
  /** Ops: wids archived by `prune` (orchestrator ledger `pruned{wid}`); they no longer exist for any reader. */
271
334
  function prunedIds(home) {
272
- return new Set(readJournalSnapshot(orchLedger(home)).filter(e => e.type === "pruned").map(e => String(e.wid)));
335
+ return ledgerIndex(readJournalSnapshot(orchLedger(home))).pruned;
336
+ }
337
+ const ledgerIndexes = new WeakMap();
338
+ export function ledgerIndex(ledger) {
339
+ let ix = ledgerIndexes.get(ledger);
340
+ if (!ix || ix.length > ledger.length || (ix.length && ledger[ix.length - 1] !== ix.last)) {
341
+ ix = { length: 0, created: new Map(), pruned: new Set(), byWid: new Map(), byOrigin: new Map() };
342
+ ledgerIndexes.set(ledger, ix);
343
+ }
344
+ for (let i = ix.length; i < ledger.length; i++) {
345
+ const e = ledger[i];
346
+ if (e.type === JT.created) {
347
+ if (!ix.created.has(e.wid))
348
+ ix.created.set(e.wid, e);
349
+ }
350
+ else if (e.type === "pruned")
351
+ ix.pruned.add(String(e.wid));
352
+ else if (e.type === "drain" || e.type === "undrain") {
353
+ if (e.type === "drain")
354
+ ix.lastDrain = i;
355
+ if (e.wid === undefined && e.origin === undefined)
356
+ ix.global = i;
357
+ if (e.wid !== undefined)
358
+ ix.byWid.set(e.wid, i);
359
+ if (e.origin !== undefined)
360
+ ix.byOrigin.set(e.origin, i);
361
+ }
362
+ }
363
+ ix.length = ledger.length;
364
+ ix.last = ledger[ledger.length - 1];
365
+ return ix;
273
366
  }
274
367
  function workflowIds(home) {
275
368
  let wids = [];
@@ -286,19 +379,21 @@ function workflowIds(home) {
286
379
  /** Drain: the one hold rule. drain/undrain entries apply to every workflow (no scope), one session's (`origin`) or one
287
380
  * workflow (`wid`). A workflow is held when the newest entry that applies to it is a drain recorded after it was created. */
288
381
  export function holdOf(ledger, wid, origin) {
289
- const applies = (e) => (e.wid === undefined && e.origin === undefined) || e.wid === wid || (origin !== undefined && e.origin === origin);
290
- const last = ledger.findLast(e => (e.type === "drain" || e.type === "undrain") && applies(e));
382
+ // The newest applicable entry is the newest of the newest unscoped, the newest for this wid and the newest for its origin.
383
+ const ix = ledgerIndex(ledger);
384
+ const at = Math.max(ix.global ?? -1, ix.byWid.get(wid) ?? -1, origin !== undefined ? ix.byOrigin.get(origin) ?? -1 : -1);
385
+ const last = at >= 0 ? ledger[at] : undefined;
291
386
  if (last?.type !== "drain")
292
387
  return undefined;
293
- const created = ledger.find(e => e.type === JT.created && e.wid === wid);
388
+ const created = ix.created.get(wid);
294
389
  return !created || created.seq < last.seq ? last : undefined;
295
390
  }
296
391
  /** Drain: the workflows a drain (stop-all, a quit pi) holds until resume, and since when. */
297
392
  export function heldWorkflows(home) {
298
- const ledger = readJournalSnapshot(orchLedger(home));
299
- const origin = (wid) => ledger.find(e => e.type === JT.created && e.wid === wid)?.origin;
393
+ const ledger = readJournalSnapshot(orchLedger(home)), ix = ledgerIndex(ledger);
394
+ const origin = (wid) => ix.created.get(wid)?.origin;
300
395
  const hold = (wid) => holdOf(ledger, wid, origin(wid));
301
- const since = ledger.filter(e => e.type === "drain").at(-1)?.ts;
396
+ const since = ix.lastDrain !== undefined ? ledger[ix.lastDrain].ts : undefined;
302
397
  return { ...(since !== undefined ? { since } : {}), held: wid => hold(wid) !== undefined };
303
398
  }
304
399
  /** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
@@ -324,7 +419,7 @@ export function compactWorkflow(wf) {
324
419
  calls: wf.calls.map(c => {
325
420
  const r = c.result, last = r?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1);
326
421
  return { key: c.key, gen: c.gen, callId: c.callId, phase: c.phase, ...(r ? { status: r.status, ok: r.ok } : {}),
327
- ...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
422
+ ...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(c.switchFailed ? { switchFailed: c.switchFailed } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
328
423
  ...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}), ...(c.hibernated ? { hibernated: true } : {}) };
329
424
  }),
330
425
  attention: wf.attention.map(a => ({ id: a.id, rev: a.rev, kind: a.kind, text: clip(a.text, 300), ...(a.call ? { call: a.call } : {}), ...(a.qid ? { qid: a.qid } : {}) })),
@@ -418,7 +513,8 @@ export function statusBrief(home, options = {}) {
418
513
  ...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
419
514
  ...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
420
515
  ...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}),
421
- ...(c.hibernated ? { hibernated: true } : {}) };
516
+ ...(c.hibernated ? { hibernated: true } : {}),
517
+ ...(live && c.switching ? { switching: c.switching } : {}), ...(live && c.switchFailed ? { switchFailed: c.switchFailed } : {}) };
422
518
  });
423
519
  const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
424
520
  ...(w.calls.some(c => c.callId === a.call && c.hibernated) ? { hibernated: true } : {}), question: clip(a.text, 300) }));
package/dist/ui/data.js CHANGED
@@ -44,6 +44,10 @@ export class LazyMap extends Map {
44
44
  this.produce(key); return super.get(key); }
45
45
  has(key) { return this.get(key) !== undefined; }
46
46
  }
47
+ /** How long after its seal a call's session is still followed. A sealed execution has been fenced, so its session is
48
+ * final: a follow-up or a revision runs as a new generation with its own session. A session changed after that (by
49
+ * hand) shows in a new pi session only. */
50
+ const FINAL_MS = 10_000;
47
51
  /** A1, P25: Read the UI's data from durable workflow snapshots and native session tails only. */
48
52
  export class UiData {
49
53
  workflows = [];
@@ -56,8 +60,21 @@ export class UiData {
56
60
  /** Where the dock sits: below the editor (default; never splits an editor header such as a powerline bar) or above. */
57
61
  dockAt = "above";
58
62
  /** Per-refresh budget (ms) for reading not yet read finished sessions (history) in the background. */
59
- warmMs = 15;
63
+ warmMs = 8;
60
64
  tails = new Map();
65
+ /** Facts of calls sealed more than FINAL_MS ago, read once after their seal; their transcript is read when shown. */
66
+ final = new Map();
67
+ /** The transcript of one final call last asked for (the open call view asks on every frame). Keeping every
68
+ * historical transcript parsed held hundreds of MB and made pi's garbage collection pause typing. */
69
+ opened;
70
+ transcript(callId, sessions) {
71
+ const final = this.final.get(callId);
72
+ if (!final)
73
+ return;
74
+ if (this.opened?.callId !== callId || this.opened.endedAt !== final.endedAt)
75
+ this.opened = { callId, endedAt: final.endedAt, entries: sessionBranch(new SessionTail().read(final.path)) };
76
+ sessions.put(callId, this.opened.entries);
77
+ }
61
78
  /** Branch and facts per call, reused while its session tail is unchanged: a finished call's session never grows, and
62
79
  * re-deriving every historical call on each refresh blocked pi's main thread for over a second. */
63
80
  derived = new Map();
@@ -65,11 +82,20 @@ export class UiData {
65
82
  constructor(home) { this.home = home; }
66
83
  refresh() {
67
84
  const workflows = allWorkflows(this.home), known = new Set(), deferred = new Map();
68
- const facts = new LazyMap(key => deferred.get(key)?.()), sessions = new LazyMap(key => deferred.get(key)?.());
85
+ const facts = new LazyMap(key => deferred.get(key)?.());
86
+ const sessions = new LazyMap(key => deferred.has(key) ? deferred.get(key)() : this.transcript(key, sessions));
69
87
  for (const w of workflows) {
70
- const index = journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
88
+ let index; // only calls still followed need it
89
+ const journal = () => index ??= journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
71
90
  for (const c of w.calls) {
72
91
  known.add(c.callId);
92
+ const final = this.final.get(c.callId);
93
+ if (final && c.phase === "sealed" && c.endedAt === final.endedAt) {
94
+ facts.put(c.callId, final.facts);
95
+ continue;
96
+ }
97
+ if (final)
98
+ this.final.delete(c.callId);
73
99
  const load = () => {
74
100
  deferred.delete(c.callId);
75
101
  let tail = this.tails.get(c.callId);
@@ -85,16 +111,23 @@ export class UiData {
85
111
  this.derived.set(c.callId, cached);
86
112
  }
87
113
  const value = { ...cached.facts, live: undefined };
88
- const proposal = index.calls.get(`${c.key}\0${c.gen}`);
114
+ const proposal = journal().calls.get(`${c.key}\0${c.gen}`);
89
115
  value.task ||= String(proposal?.spec?.task ?? "");
90
- const loss = index.losses.get(String(c.exec)) ?? -1;
91
- const launch = index.execs.get(String(c.exec)) ?? -1;
116
+ const loss = journal().losses.get(String(c.exec)) ?? -1;
117
+ const launch = journal().execs.get(String(c.exec)) ?? -1;
92
118
  if (c.phase !== "sealed" && loss > launch)
93
119
  value.activity = "connection dropped, retrying";
94
120
  if (c.phase !== "sealed")
95
121
  value.live = readLive(join(callDir(this.home, w.wid, c.key, c.gen), LIVE_FILE));
96
122
  sessions.put(c.callId, cached.entries);
97
123
  facts.put(c.callId, value);
124
+ // A call sealed a while ago writes nothing more (a follow-up opens a new generation and session), so its
125
+ // session is not stat'ed again: polling every historical session twice a second kept pi's main thread busy.
126
+ if (c.phase === "sealed" && c.endedAt !== undefined && Date.now() - c.endedAt > FINAL_MS) {
127
+ this.final.set(c.callId, { endedAt: c.endedAt, path: callSession(this.home, w.wid, c.key, c.gen), facts: value });
128
+ this.tails.delete(c.callId);
129
+ this.derived.delete(c.callId);
130
+ }
98
131
  };
99
132
  // A finished call never read before (history at startup) is read when first shown or by the idle warm-up below;
100
133
  // reading every historical session up front blocked pi's startup for seconds.
@@ -109,6 +142,9 @@ export class UiData {
109
142
  this.tails.delete(key);
110
143
  this.derived.delete(key);
111
144
  }
145
+ for (const key of this.final.keys())
146
+ if (!known.has(key))
147
+ this.final.delete(key);
112
148
  // Warm the history a slice at a time, so opening the list later finds it read without one long stall now.
113
149
  const until = performance.now() + this.warmMs;
114
150
  for (const load of [...deferred.values()]) {
package/dist/ui/screen.js CHANGED
@@ -565,7 +565,7 @@ export class SubagentScreen {
565
565
  const facts = this.data.facts.get(c.callId), active = w.calls.filter(c => c.phase !== "sealed"), done = w.calls.length - active.length;
566
566
  const tabs = size.width < 60 ? `${c.key} ${w.calls.indexOf(c) + 1}/${w.calls.length}` : `${[...active.map(c => c.key), ...(done ? [`${done} done`] : [])].join(" · ")} ← → switch`;
567
567
  const tools = toolCount(facts?.tools), pending = pendingText(c.pending), rule = this.theme.fg("borderMuted", "─".repeat(size.width));
568
- const switching = c.switching ? ` → ${this.name(c.switching)} (at the end of this step)` : "";
568
+ const switching = c.switching ? ` → ${this.name(c.switching)} (requested)` : "";
569
569
  const head = [tabs, `${label(c)} · ${this.name(facts?.model ?? c.model)} ▾${switching} · ${facts?.thinking ?? "off"} ▾${tools ? ` · ${tools}` : ""}${pending ? ` · ${pending}` : ""}`,
570
570
  this.theme.fg("dim", this.spend(c, facts)), rule];
571
571
  const asking = w.attention.some(a => a.kind === "question" && a.call === c.callId);