pi-durable-subagents 1.0.5 → 1.0.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.6
4
+
5
+ - A follow-up on a finished workflow shows as running work: in the dock, the
6
+ summary line, the list (no longer dimmed or hidden) and `status`. Quitting pi
7
+ pauses it, and `resume` continues it; before, a resume refused it as finished.
8
+ - ↓ opens the subagent list only from pi's input editor. With `/model`'s
9
+ selector, a dialog or another overlay in focus, ↓ moves in that list again.
10
+
3
11
  ## 1.0.5
4
12
 
5
13
  - `status` without a wid is brief: what runs, what asks (with the address to
@@ -12,7 +12,7 @@ import { OsLock } from "../platform/lock.js";
12
12
  import { dsaHome, orchInbox, orchLedger, orchLock, outboxRoot } from "../paths.js";
13
13
  import { CT, JT } from "../types.js";
14
14
  import { attention, presentText, presented, resolved, unfinishedWorkflow } from "./main/snapshots.js";
15
- import { pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
15
+ import { isLive, pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
16
16
  import { parameters, request } from "./main/tool.js";
17
17
  import { discoverAgents } from "../compat/agents.js";
18
18
  let noteSink;
@@ -165,7 +165,7 @@ export function registerMain(pi, ui) {
165
165
  pi.on("session_start", async (_event, context) => {
166
166
  await start(context);
167
167
  // Quit pause: say once, at the session's start, which of its workflows wait for a resume.
168
- const paused = statusView(home, { origin: sender }).workflows.filter(w => w.origin === sender && w.status === "running" && w.paused);
168
+ const paused = statusView(home, { origin: sender }).workflows.filter(w => w.origin === sender && isLive(w) && w.paused);
169
169
  if (paused.length)
170
170
  pi.sendMessage({ customType: CT.note, display: true, content: `${paused.length} subagent workflow${paused.length > 1 ? "s were" : " was"} paused when pi quit (${paused.map(w => w.name ?? w.wid).join(", ")}). Ask to resume, or open /subagents (or ↓) and press r.` });
171
171
  });
@@ -176,7 +176,7 @@ export function registerMain(pi, ui) {
176
176
  pi.on("session_shutdown", async (event) => {
177
177
  if (event.reason === "quit" && quitPolicy(home) === "pause" && outbox && !stopped) {
178
178
  try {
179
- const running = statusView(home, { origin: sender }).workflows.some(w => w.origin === sender && w.status === "running" && !w.paused);
179
+ const running = statusView(home, { origin: sender }).workflows.some(w => w.origin === sender && isLive(w) && !w.paused);
180
180
  if (running)
181
181
  await serial(async () => { await outbox?.send("orch", "drain", { fence: true, origin: sender }); });
182
182
  }
package/dist/cli/main.js CHANGED
@@ -64,7 +64,7 @@ export function renderStatus(wf) {
64
64
  }
65
65
  /** P25, T10: Render the compact status projection shared with the `subagents` tool. */
66
66
  export function renderView(view) {
67
- const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
67
+ const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.followUps ? " (follow-up running)" : ""}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
68
68
  ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
69
69
  ...w.attention.map(a => ` ${a.kind}: ${JSON.stringify(a.text.split("\n")[0])}`)].join("\n"));
70
70
  if (view.paused)
@@ -97,6 +97,11 @@ export class Engine {
97
97
  held(wid) {
98
98
  return holdOf(this.ledgers.orch.entries(), wid, this.store.workflows.get(wid)?.origin) !== undefined;
99
99
  }
100
+ /** Follow-ups (generations) of the current revision that have not ended: on a finished workflow they are its only live work. */
101
+ openFollowUps(wf) {
102
+ const log = revisionEntries(wf), ended = new Set(log.filter(e => e.type === JT.sealed || e.type === 'retired').map(e => String(e.call)));
103
+ return log.some(e => e.type === 'generation' && !ended.has(`${wf.wid}@${wf.revision}/${e.key}@${e.gen}`));
104
+ }
100
105
  watcher;
101
106
  poll;
102
107
  constructor(ledgers, executor, options = {}) {
@@ -370,7 +375,8 @@ export class Engine {
370
375
  const inScope = (wf) => wid ? wf.wid === wid : origin ? wf.origin === origin : true;
371
376
  const took = (wf) => wf.journal.entries().some(e => e.type === 'resumed' && e.rid === req.rid);
372
377
  const replayed = this.ledgers.orch.entries().some(e => e.type === 'undrain' && e.rid === req.rid);
373
- const holding = [...this.store.workflows.values()].filter(wf => inScope(wf) && this.held(wf.wid) && !this.terminal(wf));
378
+ // Held follow-ups of a finished workflow are released too; they run again through dispatchGeneration below.
379
+ const holding = [...this.store.workflows.values()].filter(wf => inScope(wf) && this.held(wf.wid) && (!this.terminal(wf) || this.openFollowUps(wf)));
374
380
  const releasing = replayed || holding.length > 0;
375
381
  const final = (wf) => { const done = this.terminal(wf); return done && done.status !== 'parked' ? String(done.status) : undefined; };
376
382
  const parked = (wf) => this.terminal(wf)?.status === 'parked';
@@ -6,6 +6,14 @@ import { compileFanout } from "../compat/fanout.js";
6
6
  import { readJournalSnapshot } from "../kernel/journal.js";
7
7
  import { journalPath, orchLedger, pinnedDir, workflowDir } from "../paths.js";
8
8
  import { JT } from "../types.js";
9
+ /** A workflow has live work: it runs, or follow-ups opened on it after it finished have not ended yet. */
10
+ export function isLive(wf) {
11
+ return wf.status === "running" || (wf.followUps ?? 0) > 0;
12
+ }
13
+ /** The calls of a workflow that are live work: every unfinished call of a running workflow, else open follow-ups. */
14
+ export function liveCalls(wf) {
15
+ return wf.calls.filter(c => c.phase !== "sealed" && (wf.status === "running" || c.afterEnd === true));
16
+ }
9
17
  /** v12 §4: done/total of one workflow: `done` counts keys whose latest generation is sealed; `total` is the planned
10
18
  * count when known, else keys proposed so far (`plus` marks the `n+` script case while the workflow runs). */
11
19
  export function progressOf(wf) {
@@ -69,6 +77,7 @@ export function snapshotFromEntries(wid, entries) {
69
77
  const calls = new Map();
70
78
  const byExec = new Map();
71
79
  const resolved = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}@${e.rev}`));
80
+ const generations = new Set(), retired = new Set();
72
81
  const attention = [];
73
82
  // P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
74
83
  const live = new Map(), sealedUsage = new Map(), seen = new Set();
@@ -81,6 +90,8 @@ export function snapshotFromEntries(wid, entries) {
81
90
  continue;
82
91
  const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
83
92
  const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
93
+ if (e.type === "generation")
94
+ generations.add(callId);
84
95
  const result = e.type === "refused" ? refusedResult(key, e.reason) :
85
96
  e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
86
97
  const wanted = e.spec?.model;
@@ -90,6 +101,9 @@ export function snapshotFromEntries(wid, entries) {
90
101
  phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
91
102
  ...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
92
103
  }
104
+ else if (e.type === "retired") {
105
+ retired.add(String(e.call));
106
+ }
93
107
  else if (e.type === JT.exec) {
94
108
  const call = calls.get(String(e.call));
95
109
  if (!call)
@@ -197,6 +211,10 @@ export function snapshotFromEntries(wid, entries) {
197
211
  c.switching = switching;
198
212
  }
199
213
  }
214
+ const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
215
+ for (const c of after)
216
+ c.afterEnd = true;
217
+ const followUps = after.length;
200
218
  const usage = zero();
201
219
  for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
202
220
  const u = usageOf(id);
@@ -210,6 +228,7 @@ export function snapshotFromEntries(wid, entries) {
210
228
  status: done ? done.status : "running",
211
229
  ...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
212
230
  ...(done ? { endedAt: done.ts } : {}),
231
+ ...(followUps ? { followUps } : {}),
213
232
  calls: list, counts, attention, usage,
214
233
  };
215
234
  }
@@ -262,7 +281,7 @@ export function heldWorkflows(home) {
262
281
  /** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
263
282
  export function allWorkflows(home) {
264
283
  const { held } = heldWorkflows(home);
265
- return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return wf.status === "running" && held(wid) ? { ...wf, paused: true } : wf; });
284
+ return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return isLive(wf) && held(wid) ? { ...wf, paused: true } : wf; });
266
285
  }
267
286
  /** P10/P11: Per-workflow script console log written by the orchestrator (bounded, human-readable). */
268
287
  export function scriptLogPath(home, wid) { return join(workflowDir(home, wid), "script.log"); }
@@ -286,7 +305,7 @@ export function compactWorkflow(wf) {
286
305
  ...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}) };
287
306
  }),
288
307
  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 } : {}) })),
289
- ...(wf.paused ? { paused: true } : {}),
308
+ ...(wf.paused ? { paused: true } : {}), ...(wf.followUps ? { followUps: wf.followUps } : {}),
290
309
  };
291
310
  }
292
311
  function origins(home) {
@@ -306,7 +325,7 @@ function snapshots(home, origin) {
306
325
  const { since, held } = heldWorkflows(home);
307
326
  const all = [...origins(home)].sort(([a], [b]) => a < b ? 1 : a > b ? -1 : 0).map(([wid, origin]) => {
308
327
  const wf = workflowSnapshot(home, wid), withOrigin = wf.origin === undefined && origin !== undefined ? { ...wf, origin } : wf;
309
- return withOrigin.status === "running" && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
328
+ return isLive(withOrigin) && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
310
329
  }).sort((a, b) => own(b) - own(a));
311
330
  return { all, ...(since !== undefined ? { since } : {}) };
312
331
  }
@@ -315,13 +334,15 @@ export function statusView(home, options = {}) {
315
334
  const keep = options.keep ?? 10;
316
335
  const { all, since } = snapshots(home, options.origin);
317
336
  let finished = 0;
318
- const shown = all.filter(w => !["done", "failed", "stopped"].includes(w.status) || ++finished <= keep);
337
+ const shown = all.filter(w => !settled(w) || ++finished <= keep);
319
338
  const hidden = all.length - shown.length;
320
339
  const paused = all.filter(w => w.paused).length;
321
340
  return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
322
341
  ...(paused ? { paused: `${paused} workflow${paused > 1 ? "s" : ""} paused (stop-all, drain or a quit pi) since ${new Date(since).toISOString()}; resume continues them (new runs are not affected)` } : {}) };
323
342
  }
324
343
  const FINAL = ["done", "failed", "stopped"];
344
+ /** Finished with nothing left running (a follow-up on a finished workflow is live work). */
345
+ const settled = (w) => FINAL.includes(w.status) && !w.followUps;
325
346
  const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${(ms / 3_600_000).toFixed(1)}h`;
326
347
  const tokens = (u) => { const n = u ? u.input + u.output : 0; return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n); };
327
348
  /** The latest generation of every key, in first-call order. */
@@ -334,7 +355,7 @@ export function statusBrief(home, options = {}) {
334
355
  const mine = (w) => !origin || w.origin === origin;
335
356
  const line = (w) => {
336
357
  const p = progressOf(w), notOk = latestCalls(w).filter(c => c.result && !c.result.ok).length;
337
- return [w.wid, w.name, `${w.paused ? "paused" : w.status}`, `${p.done}/${p.total}${p.plus ? "+" : ""} done`, notOk ? `${notOk} not ok` : "",
358
+ return [w.wid, w.name, `${w.paused ? "paused" : w.followUps ? `${w.status}, follow-up running` : w.status}`, `${p.done}/${p.total}${p.plus ? "+" : ""} done`, notOk ? `${notOk} not ok` : "",
338
359
  w.endedAt !== undefined ? `ended ${age(now - w.endedAt)} ago` : w.startedAt !== undefined ? `started ${age(now - w.startedAt)} ago` : ""].filter(Boolean).join(" · ");
339
360
  };
340
361
  const brief = (w) => {
@@ -349,12 +370,12 @@ export function statusBrief(home, options = {}) {
349
370
  });
350
371
  const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}), question: clip(a.text, 300) }));
351
372
  const alerts = open.filter(a => a.kind !== "question").map(a => `${a.kind}${a.call ? ` ${w.wid}/${callKey(a.call)}` : ""}: ${clip(a.text, 200)}`);
352
- return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}),
373
+ return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}), ...(w.followUps ? { followUps: w.followUps } : {}),
353
374
  progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
354
375
  ...(asking.length ? { asking } : {}), ...(alerts.length ? { alerts } : {}) };
355
376
  };
356
- const active = all.filter(w => !FINAL.includes(w.status));
357
- const finished = all.filter(w => FINAL.includes(w.status) && mine(w));
377
+ const active = all.filter(w => !settled(w));
378
+ const finished = all.filter(w => settled(w) && mine(w));
358
379
  const others = active.filter(w => !mine(w));
359
380
  const heldMine = active.filter(w => mine(w) && w.paused).length, heldOthers = others.filter(w => w.paused);
360
381
  const paused = [heldMine ? `${heldMine} workflow${heldMine > 1 ? "s" : ""} of this session ${heldMine > 1 ? "are" : "is"} paused (stop-all, drain or a quit pi); resume continues ${heldMine > 1 ? "them" : "it"}` : "",
@@ -368,7 +389,7 @@ export function statusBrief(home, options = {}) {
368
389
  }
369
390
  /** Running workflows of other sessions that a pause holds, as "wid" or "wid (name)": a session's own resume skips them. */
370
391
  export function pausedElsewhere(home, origin) {
371
- return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !FINAL.includes(w.status)).map(w => w.name ? `${w.wid} (${w.name})` : w.wid);
392
+ return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !settled(w)).map(w => w.name ? `${w.wid} (${w.name})` : w.wid);
372
393
  }
373
394
  /** "<rid>" or "<rid>/<rest>" of a run request that created a workflow → the same address with its wid; else unchanged.
374
395
  * A run replies {submitted:{rid}} when its workflow is not created within 10 s, so the rid is all the caller has. */
package/dist/ui/index.js CHANGED
@@ -38,7 +38,19 @@ export function registerUi(pi, deps) {
38
38
  const state = { folded: new Set(), done: new Map(), viewed: new Set(), finished: false };
39
39
  let screen, opening = false, stopped = false, closeScreen;
40
40
  let unsubscribe, timer;
41
- let dockRows = () => [], dockAt, dockTui, lastDock = "";
41
+ let dockRows = () => [], dockAt = null, dockTui, lastDock = "";
42
+ // ↓ opens the list only from pi's own input editor. Another surface — /model's selector, a dialog, another
43
+ // extension's overlay — has the focus instead, and its ↓ belongs to it. pi's editor (and any editor built on
44
+ // CustomEditor) carries the app action map; the TUI comes from the dock widget, so without a dock the check
45
+ // falls back to the editor text alone.
46
+ let keysTui;
47
+ const editorFocused = () => {
48
+ const focus = keysTui?.getFocusedComponent;
49
+ if (typeof focus !== "function")
50
+ return true;
51
+ const focused = focus.call(keysTui);
52
+ return !!focused && typeof focused === "object" && focused.actionHandlers instanceof Map;
53
+ };
42
54
  const right = (text, width) => { const t = truncateToWidth(text, width); return " ".repeat(Math.max(0, width - visibleWidth(t))) + t; };
43
55
  const stop = () => {
44
56
  stopped = true;
@@ -75,8 +87,11 @@ export function registerUi(pi, deps) {
75
87
  if (at !== dockAt) {
76
88
  dockAt = at;
77
89
  dockTui = undefined;
78
- ctx.ui.setWidget("durable-subagents", at ? (tui, theme) => {
90
+ // With the dock off, an empty widget below the editor still lends the TUI to the ↓ focus check; pi adds no
91
+ // spacer for it, so it takes no line.
92
+ ctx.ui.setWidget("durable-subagents", !at ? tui => { keysTui = tui; return { invalidate() { }, render: () => [] }; } : (tui, theme) => {
79
93
  dockTui = tui;
94
+ keysTui = tui;
80
95
  return {
81
96
  invalidate() { },
82
97
  render(width) {
@@ -88,7 +103,7 @@ export function registerUi(pi, deps) {
88
103
  return rows.map((row, i) => ` ${i === rows.length - 1 ? theme.fg("dim", right(row, inner)) : row.startsWith("? ") ? theme.fg("warning", row) : theme.fg("muted", row)}`);
89
104
  },
90
105
  };
91
- } : undefined, { placement: at === "above" ? "aboveEditor" : "belowEditor" });
106
+ }, { placement: at === "above" ? "aboveEditor" : "belowEditor" });
92
107
  }
93
108
  const shown = dockRows(200).join("\n");
94
109
  if (shown !== lastDock) {
@@ -118,7 +133,7 @@ export function registerUi(pi, deps) {
118
133
  };
119
134
  openList = open;
120
135
  unsubscribe = ctx.ui.onTerminalInput(key => {
121
- if (stopped || opening || !matchesKey(key, "down") || ctx.ui.getEditorText() !== "" || !data.workflows.length)
136
+ if (stopped || opening || !matchesKey(key, "down") || ctx.ui.getEditorText() !== "" || !editorFocused() || !data.workflows.length)
122
137
  return;
123
138
  open();
124
139
  return { consume: true };
package/dist/ui/tool.js CHANGED
@@ -50,7 +50,7 @@ export function resultLines(details) {
50
50
  if (Array.isArray(d.active)) {
51
51
  const rows = d.active.map(w => {
52
52
  const failed = w.calls.filter(c => c.status && c.status !== "ok").length, asks = w.asking?.length ?? 0, alerts = w.alerts?.length ?? 0;
53
- return `${w.name ?? short(w.wid)} · ${w.paused ? "paused" : w.status} · ${w.progress} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}${alerts ? ` · ${alerts} alert${alerts > 1 ? "s" : ""}` : ""}`;
53
+ return `${w.name ?? short(w.wid)} · ${w.paused ? "paused" : w.followUps ? `${w.status}, follow-up running` : w.status} · ${w.progress} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}${alerts ? ` · ${alerts} alert${alerts > 1 ? "s" : ""}` : ""}`;
54
54
  });
55
55
  const finished = Array.isArray(d.finished) ? d.finished.length + Number(d.olderFinished ?? 0) : 0;
56
56
  return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["nothing running"]),
package/dist/ui/view.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { progressOf } from "../orchestrator/snapshot.js";
2
+ import { isLive, liveCalls, progressOf } from "../orchestrator/snapshot.js";
3
3
  import { oneLine } from "./frame.js";
4
4
  /** UI §2: Render compact wall-clock durations; never present these as charged active time. */
5
5
  export function duration(ms) {
@@ -146,18 +146,18 @@ export const resultWord = (status) => WORDS[status] ?? "failed";
146
146
  const WORD_ORDER = ["done", "stopped", "failed", "timeout", "budget", "unknown", "skipped", "parked"];
147
147
  /** UI §1,4, v12 §4: One working sentence; done/total uses planned totals, `n+` while a script workflow keeps proposing. */
148
148
  export function summary(workflows) {
149
- const running = workflows.filter(w => w.status === "running"), open = (w) => w.calls.filter(c => c.phase !== "sealed").length;
150
- const asking = running.reduce((n, w) => n + w.calls.filter(c => c.phase !== "sealed" && w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
149
+ const running = workflows.filter(isLive), open = (w) => liveCalls(w).length;
150
+ const asking = running.reduce((n, w) => n + liveCalls(w).filter(c => w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
151
151
  const paused = running.filter(w => w.paused).reduce((n, w) => n + open(w), 0); // held work is not working: it burns nothing
152
152
  // Waiting for a slot is not working either: nothing is spent until it launches.
153
- const queued = running.filter(w => !w.paused).reduce((n, w) => n + w.calls.filter(c => c.phase === "queued" && !w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
153
+ const queued = running.filter(w => !w.paused).reduce((n, w) => n + liveCalls(w).filter(c => c.phase === "queued" && !w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
154
154
  const progress = running.map(progressOf);
155
155
  return { working: running.reduce((n, w) => n + open(w), 0) - asking - paused - queued, asking, queued, paused,
156
156
  done: progress.reduce((n, p) => n + p.done, 0), total: progress.reduce((n, p) => n + p.total, 0), plus: progress.some(p => p.plus) };
157
157
  }
158
158
  /** UI §1, v12 §4: What needs you first, then what is running, then progress — e.g. "1 asking · 3 working · 12/40 done". */
159
159
  export function summaryText(workflows) {
160
- const s = summary(workflows), finished = workflows.filter(w => w.status !== "running").length;
160
+ const s = summary(workflows), finished = workflows.filter(w => !isLive(w)).length;
161
161
  if (!s.total)
162
162
  return finished ? `${finished} finished` : "nothing running";
163
163
  return [s.asking ? `${s.asking} asking` : "", s.working ? `${s.working} working` : "", s.queued ? `${s.queued} queued` : "", s.paused ? `${s.paused} paused` : "", `${s.done}/${s.total}${s.plus ? "+" : ""} done`].filter(Boolean).join(" · ");
@@ -169,7 +169,7 @@ export function mainLine(workflows) {
169
169
  // The key that opens the list is spelled out: a bare arrow is easy to miss.
170
170
  if (s.working || s.asking || s.queued || s.paused)
171
171
  return `${summaryText(workflows)} · ↓ subagents`;
172
- const w = workflows.filter(w => w.status !== "running").sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
172
+ const w = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
173
173
  if (!w)
174
174
  return undefined;
175
175
  const latest = [...new Map(w.calls.map(c => [c.key, c])).values()]; // the newest generation of each key (P37)
@@ -188,15 +188,16 @@ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
188
188
  * then one summary line; afterwards only the completion sentence for ten minutes; otherwise nothing. Activity spins
189
189
  * only while there is fresh evidence, so a quiet agent visibly stops moving. */
190
190
  export function dockLines(workflows, facts, name, width, now = Date.now(), rows = 3) {
191
- const live = workflows.filter(w => w.status === "running");
191
+ const live = workflows.filter(isLive);
192
192
  const active = live.flatMap(w => {
193
193
  const latest = new Map();
194
194
  for (const c of w.calls)
195
195
  latest.set(c.key, c);
196
- return [...latest.values()].filter(c => c.phase !== "sealed").map(c => ({ w, c, asking: w.attention.some(a => a.kind === "question" && a.call === c.callId) }));
196
+ const open = new Set(liveCalls(w));
197
+ return [...latest.values()].filter(c => open.has(c)).map(c => ({ w, c, asking: w.attention.some(a => a.kind === "question" && a.call === c.callId) }));
197
198
  }).sort((a, b) => Number(b.asking) - Number(a.asking));
198
199
  if (!active.length) {
199
- const ended = workflows.filter(w => w.status !== "running").sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
200
+ const ended = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
200
201
  const line = ended && now - (ended.endedAt ?? 0) < 10 * 60_000 ? mainLine(workflows) : undefined;
201
202
  return line ? [truncateToWidth(line, Math.max(1, width))] : [];
202
203
  }
@@ -217,7 +218,7 @@ export function dockLines(workflows, facts, name, width, now = Date.now(), rows
217
218
  * keep unviewed failures visible, and page newest done rows eight at a time. */
218
219
  /** v12 §5: Whether a workflow shows its agents: running ones unless folded; finished ones once opened (Enter, or on completion). */
219
220
  export function isOpen(w, state) {
220
- return !state.folded.has(w.wid) && (w.status === "running" || (state.done.get(w.wid) ?? 0) > 0);
221
+ return !state.folded.has(w.wid) && (isLive(w) || (state.done.get(w.wid) ?? 0) > 0);
221
222
  }
222
223
  /** v12 §5: Enter on a workflow row opens or closes it. */
223
224
  export function toggleOpen(w, state) {
@@ -225,7 +226,7 @@ export function toggleOpen(w, state) {
225
226
  state.folded.add(w.wid);
226
227
  else {
227
228
  state.folded.delete(w.wid);
228
- if (w.status !== "running")
229
+ if (!isLive(w))
229
230
  state.done.set(w.wid, Math.max(8, state.done.get(w.wid) ?? 0));
230
231
  }
231
232
  }
@@ -267,13 +268,13 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
267
268
  for (const c of v.calls)
268
269
  latest.set(c.key, c);
269
270
  const w = latest.size === v.calls.length ? v : { ...v, calls: v.calls.filter(c => latest.get(c.key) === c) };
270
- const dim = w.status !== "running"; // v12 §5: finished workflows are dimmed, not hidden
271
+ const dim = !isLive(w); // v12 §5: finished workflows are dimmed, not hidden; an open follow-up keeps one live
271
272
  if (w.calls.length === 1) {
272
273
  callRow(w, w.calls[0], " ", " ", dim);
273
274
  continue;
274
275
  }
275
276
  // Stable order (UI §2): active rows keep proposal (snapshot) order; done rows by immutable end time.
276
- const done = doneOrder(w.calls), active = w.calls.filter(c => c.phase !== "sealed"), folded = !isOpen(w, state);
277
+ const done = doneOrder(w.calls), active = liveCalls(w), folded = !isOpen(w, state);
277
278
  const progress = progressOf(w); // v12 §4: done/planned, `n+` while a script workflow keeps proposing
278
279
  rows.push({ id: w.wid, kind: "workflow", workflow: w, dim, text: `${folded ? "▸" : "▾"} ${w.name ?? w.wid} · ${progress.done}/${progress.total}${progress.plus ? "+" : ""} · ${duration((w.endedAt ?? now) - (w.startedAt ?? now))}` });
279
280
  if (folded)
@@ -285,7 +286,7 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
285
286
  active.forEach((c, i) => { const last = i === active.length - 1 && !done.length; callRow(w, c, last ? " └ " : " ├ ", last ? " " : " │ ", dim); });
286
287
  if (!done.length)
287
288
  continue;
288
- const count = state.done.get(w.wid) ?? (w.status === "running" && !active.length || unviewed(w) ? 8 : 0), shown = done.slice(0, count);
289
+ const count = state.done.get(w.wid) ?? (isLive(w) && !active.length || unviewed(w) ? 8 : 0), shown = done.slice(0, count);
289
290
  rows.push({ id: `${w.wid}:done`, kind: "done", workflow: w, dim, text: ` └ ${count ? "▾" : "▸"} ${progress.done} done` });
290
291
  const more = count && done.length > count;
291
292
  shown.forEach((c, i) => callRow(w, c, i === shown.length - 1 && !more ? " └ " : " ├ ", undefined, dim));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",