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 +8 -0
- package/dist/agent/main.js +3 -3
- package/dist/cli/main.js +1 -1
- package/dist/orchestrator/engine.js +7 -1
- package/dist/orchestrator/snapshot.js +30 -9
- package/dist/ui/index.js +19 -4
- package/dist/ui/tool.js +1 -1
- package/dist/ui/view.js +15 -14
- package/package.json +1 -1
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
|
package/dist/agent/main.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 => !
|
|
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 => !
|
|
357
|
-
const finished = all.filter(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 && !
|
|
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
|
-
|
|
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
|
-
}
|
|
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(
|
|
150
|
-
const asking = running.reduce((n, w) => n + w.
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|