pi-durable-subagents 1.0.4 → 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 +31 -0
- package/README.md +11 -1
- package/dist/agent/main/snapshots.js +8 -0
- package/dist/agent/main/tool.js +19 -1
- package/dist/agent/main.js +45 -14
- package/dist/cli/chaos/scenario.js +2 -1
- package/dist/cli/main.js +1 -1
- package/dist/compat/spec.js +11 -9
- package/dist/orchestrator/engine.js +7 -1
- package/dist/orchestrator/executor/index.js +4 -2
- package/dist/orchestrator/executor/observe.js +32 -1
- package/dist/orchestrator/executor/session.js +7 -1
- package/dist/orchestrator/snapshot.js +116 -7
- package/dist/ui/cards.js +2 -1
- package/dist/ui/index.js +19 -4
- package/dist/ui/tool.js +13 -0
- package/dist/ui/view.js +21 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
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
|
+
|
|
11
|
+
## 1.0.5
|
|
12
|
+
|
|
13
|
+
- `status` without a wid is brief: what runs, what asks (with the address to
|
|
14
|
+
answer) and what failed, with finished workflows one line each; it used to
|
|
15
|
+
return every call's usage and last line (tens of thousands of tokens on a
|
|
16
|
+
busy home). With a wid, outputs are clipped; `key` gives one call's full
|
|
17
|
+
result and `full: true` the previous complete detail.
|
|
18
|
+
- A run's rid (from `{submitted: {rid}}`) works wherever a wid is expected.
|
|
19
|
+
- With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`,
|
|
20
|
+
`isolation`, `context`, `tools`, `skills` and `once` are defaults for every
|
|
21
|
+
step; other fields there, or any call field beside a workflow script, are
|
|
22
|
+
refused instead of silently ignored. Invalid fields report the value
|
|
23
|
+
received.
|
|
24
|
+
- A call that keeps running without producing output tokens or tool results
|
|
25
|
+
(for example, retrying against an exhausted provider) raises a
|
|
26
|
+
"no progress" alert after 10 minutes (`k.progressMs`), with the last
|
|
27
|
+
provider error. A call that ends on an explicit quota or billing error fails
|
|
28
|
+
at once with `Provider error: …` instead of being relaunched; other lost
|
|
29
|
+
executions report their last error.
|
|
30
|
+
- Attention reaches the main agent with the call's address; a question says
|
|
31
|
+
how to answer it.
|
|
32
|
+
- `resume` without a wid names workflows that other sessions hold paused.
|
|
33
|
+
|
|
3
34
|
## 1.0.4
|
|
4
35
|
|
|
5
36
|
- The TUI no longer stalls pi with a long subagent history. Its 500 ms refresh
|
package/README.md
CHANGED
|
@@ -72,6 +72,16 @@ subagents({ action: "send", to: "<wid>/<key>", kind: "steer", message: "Don't to
|
|
|
72
72
|
subagents({ action: "status" })
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
`status` without a wid is brief: what runs, what asks (with the address to
|
|
76
|
+
answer) or failed, and one line per finished workflow. `status` with a wid
|
|
77
|
+
shows one workflow with outputs clipped; add `key` for one call's full result,
|
|
78
|
+
or `full: true` for everything. When a run replies `{submitted: {rid}}`
|
|
79
|
+
(its workflow was not created within 10 s), the rid works wherever a wid does.
|
|
80
|
+
With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`, `isolation`,
|
|
81
|
+
`context`, `tools`, `skills` and `once` apply to every step that does not set
|
|
82
|
+
its own; other call fields there, and any of them beside a workflow script,
|
|
83
|
+
are refused rather than ignored.
|
|
84
|
+
|
|
75
85
|
Every run is asynchronous. The agent is woken once, when the workflow
|
|
76
86
|
finishes (the notice carries each subagent's result) or when a subagent asks
|
|
77
87
|
it something. Each verb means one thing, and a refusal says what would work:
|
|
@@ -79,7 +89,7 @@ it something. Each verb means one thing, and a refusal says what would work:
|
|
|
79
89
|
| Verb | Applies to | Effect |
|
|
80
90
|
|---|---|---|
|
|
81
91
|
| `run` | — | Start one subagent, `tasks` in parallel, a `chain`, or a workflow script. An unknown agent name is refused before anything starts, with the list of agents. |
|
|
82
|
-
| `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up
|
|
92
|
+
| `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`; To one waiting on its question: it interrupts the question, and the subagent usually asks again; `answer` answers it. |
|
|
83
93
|
| `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). |
|
|
84
94
|
| `send answer` | an open question | Answers it once. |
|
|
85
95
|
| `send model` | any subagent | Switches its model at the next request. |
|
|
@@ -78,6 +78,14 @@ export function attention(home, sender, seen) {
|
|
|
78
78
|
}
|
|
79
79
|
return items;
|
|
80
80
|
}
|
|
81
|
+
/** What the main agent reads for an attention item: the text, addressed. A question's own text named neither the asking
|
|
82
|
+
* call nor how to answer it, so answers went out as steers or to the wrong id. */
|
|
83
|
+
export function presentText(item) {
|
|
84
|
+
const call = item.call ? item.call.replace(/@\d+\/([^@/]+)@\d+$/, "/$1") : undefined, where = call ?? item.wid;
|
|
85
|
+
if (item.kind === "question" && call)
|
|
86
|
+
return `Question from ${call}${item.qid ? ` (qid ${item.qid})` : ""}; reply with send kind:"answer" to:"${call}": ${item.text}`;
|
|
87
|
+
return item.text.includes(item.wid) ? item.text : `${where}: ${item.text}`;
|
|
88
|
+
}
|
|
81
89
|
/** P15: Refresh a question against durable workflow and child receipts at request time. */
|
|
82
90
|
export function resolved(home, item) {
|
|
83
91
|
if (readJournalSnapshot(journalPath(home, item.wid)).some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
|
package/dist/agent/main/tool.js
CHANGED
|
@@ -16,7 +16,13 @@ export const parameters = Type.Object({
|
|
|
16
16
|
replaces: Type.Optional(Type.Array(Type.String())), target: Type.Optional(Type.String()), wid: Type.Optional(Type.String()),
|
|
17
17
|
usageBudget: Type.Optional(Type.Object({ tokens: Type.Optional(Type.Number()), costUsd: Type.Optional(Type.Number()) })),
|
|
18
18
|
maxCalls: Type.Optional(Type.Integer({ minimum: 1 })), inputs: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
19
|
+
name: Type.Optional(Type.String()),
|
|
20
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Per-call limit on active time in milliseconds (a number). Omit unless a hard limit is needed; prefer budgets." })),
|
|
21
|
+
key: Type.Optional(Type.String({ description: "A single agent/task run: the call's key. status with wid: that call's full result." })),
|
|
22
|
+
full: Type.Optional(Type.Boolean({ description: "status: with wid, the complete workflow detail including every output." })),
|
|
19
23
|
}, { additionalProperties: true });
|
|
24
|
+
/** Call fields a tasks/chain run applies to every step that does not set its own. */
|
|
25
|
+
export const stepDefaults = ["model", "timeoutMs", "budget", "isolation", "context", "tools", "skills", "once"];
|
|
20
26
|
function string(args, name) {
|
|
21
27
|
if (typeof args[name] !== "string" || !args[name])
|
|
22
28
|
throw new Error(`${name} is required`);
|
|
@@ -49,6 +55,18 @@ export function request(args, cwd) {
|
|
|
49
55
|
// inputs, per-call cwd) resolve against it and calls default to it. It used to be ignored, so a relative
|
|
50
56
|
// workflow path was looked up in the session's directory instead.
|
|
51
57
|
const runCwd = choices[4] === undefined && typeof spec.cwd === "string" && spec.cwd ? resolve(cwd, spec.cwd) : cwd;
|
|
58
|
+
// Call fields beside a tasks/chain list are defaults for its steps; anything else beside a list or a script was
|
|
59
|
+
// silently dropped before (a top-level model or timeoutMs did nothing), so it is an error now.
|
|
60
|
+
const extra = choices[4] === undefined ? Object.keys(spec).filter(k => k !== "cwd" && spec[k] !== undefined) : [];
|
|
61
|
+
const fields = (keys) => keys.map(k => `"${k}"`).join(", ");
|
|
62
|
+
if (tasks !== undefined || chain !== undefined) {
|
|
63
|
+
const bad = extra.filter(k => !stepDefaults.includes(k));
|
|
64
|
+
if (bad.length)
|
|
65
|
+
throw new Error(`${fields(bad)} cannot be set for a whole ${tasks !== undefined ? "tasks" : "chain"} run; set ${bad.length > 1 ? "them" : "it"} in each step (run-level step defaults: ${stepDefaults.join(", ")})`);
|
|
66
|
+
}
|
|
67
|
+
else if (extra.length)
|
|
68
|
+
throw new Error(`${fields(extra)} cannot be set for a ${workflow !== undefined ? "workflow" : "source"} run; set ${extra.length > 1 ? "them" : "it"} in the script's runs.run(key, spec) calls`);
|
|
69
|
+
const defaults = Object.fromEntries(extra.map(k => [k, spec[k]]));
|
|
52
70
|
const body = { cwd: runCwd };
|
|
53
71
|
if (workflow !== undefined)
|
|
54
72
|
body.workflow = resolve(runCwd, string(args, "workflow"));
|
|
@@ -59,7 +77,7 @@ export function request(args, cwd) {
|
|
|
59
77
|
if (!Array.isArray(list) || !list.length)
|
|
60
78
|
throw new Error("tasks/chain must be nonempty");
|
|
61
79
|
const kind = tasks !== undefined ? "tasks" : "chain";
|
|
62
|
-
body[kind] = list.map((value, i) => call(value, runCwd, `${kind}[${i}]`));
|
|
80
|
+
body[kind] = list.map((value, i) => call(value && typeof value === "object" && !Array.isArray(value) ? { ...defaults, ...value } : value, runCwd, `${kind}[${i}]`));
|
|
63
81
|
compileFanout(kind === "tasks" ? { tasks: body.tasks } : { chain: body.chain }); // duplicate keys fail here, not at admission
|
|
64
82
|
}
|
|
65
83
|
else
|
package/dist/agent/main.js
CHANGED
|
@@ -11,8 +11,8 @@ import { reduceLifecycle } from "../kernel/lifecycle.js";
|
|
|
11
11
|
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
|
-
import { attention, presented, resolved, unfinishedWorkflow } from "./main/snapshots.js";
|
|
15
|
-
import { statusDetail, statusView } from "../orchestrator/snapshot.js";
|
|
14
|
+
import { attention, presentText, presented, resolved, unfinishedWorkflow } from "./main/snapshots.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;
|
|
@@ -61,7 +61,8 @@ export function registerMain(pi, ui) {
|
|
|
61
61
|
if (entry.type === JT.created)
|
|
62
62
|
await outbox?.markResolved(String(entry.rid));
|
|
63
63
|
}
|
|
64
|
-
function pendingOutbox() {
|
|
64
|
+
function pendingOutbox() { return pendingRids().size > 0; }
|
|
65
|
+
function pendingRids() {
|
|
65
66
|
const pending = new Set();
|
|
66
67
|
for (const entry of readJournalSnapshot(join(outboxRoot(home), "outbox", `${sender}.jsonl`))) {
|
|
67
68
|
if (entry.type === "sent")
|
|
@@ -69,7 +70,7 @@ export function registerMain(pi, ui) {
|
|
|
69
70
|
else if (entry.type === "resolved")
|
|
70
71
|
pending.delete(String(entry.rid));
|
|
71
72
|
}
|
|
72
|
-
return pending
|
|
73
|
+
return pending;
|
|
73
74
|
}
|
|
74
75
|
async function starter(submitting = false) {
|
|
75
76
|
if (stopped)
|
|
@@ -90,7 +91,7 @@ export function registerMain(pi, ui) {
|
|
|
90
91
|
}
|
|
91
92
|
function collect() { return ctx ? attention(home, sender, [...presented(ctx), ...reserved]) : []; }
|
|
92
93
|
function message(items) {
|
|
93
|
-
return { type: "custom_message", customType: CT.attention, content: items.map(
|
|
94
|
+
return { type: "custom_message", customType: CT.attention, content: items.map(presentText).join("\n"), display: true, details: { items } };
|
|
94
95
|
}
|
|
95
96
|
async function idle() {
|
|
96
97
|
if (stopped || !ctx?.isIdle())
|
|
@@ -164,7 +165,7 @@ export function registerMain(pi, ui) {
|
|
|
164
165
|
pi.on("session_start", async (_event, context) => {
|
|
165
166
|
await start(context);
|
|
166
167
|
// Quit pause: say once, at the session's start, which of its workflows wait for a resume.
|
|
167
|
-
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);
|
|
168
169
|
if (paused.length)
|
|
169
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.` });
|
|
170
171
|
});
|
|
@@ -175,7 +176,7 @@ export function registerMain(pi, ui) {
|
|
|
175
176
|
pi.on("session_shutdown", async (event) => {
|
|
176
177
|
if (event.reason === "quit" && quitPolicy(home) === "pause" && outbox && !stopped) {
|
|
177
178
|
try {
|
|
178
|
-
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);
|
|
179
180
|
if (running)
|
|
180
181
|
await serial(async () => { await outbox?.send("orch", "drain", { fence: true, origin: sender }); });
|
|
181
182
|
}
|
|
@@ -193,7 +194,7 @@ export function registerMain(pi, ui) {
|
|
|
193
194
|
const items = msg.details?.items;
|
|
194
195
|
if (!items)
|
|
195
196
|
return msg;
|
|
196
|
-
return { ...msg, content: items.map(item => resolved(home, item) ? `(resolved: ${item
|
|
197
|
+
return { ...msg, content: items.map(item => resolved(home, item) ? `(resolved: ${presentText(item)})` : presentText(item)).join("\n") };
|
|
197
198
|
}) }));
|
|
198
199
|
/** P25, P38, T6, T10: One durable submission path for the tool and the UI; replies say what happened when known within 10 s. */
|
|
199
200
|
/** v12 §2, §6: An answer finds its open question (qid, rev and target) from whatever the caller gave; a send without a
|
|
@@ -218,9 +219,39 @@ export function registerMain(pi, ui) {
|
|
|
218
219
|
}
|
|
219
220
|
return args;
|
|
220
221
|
}
|
|
222
|
+
/** "<rid>" or "<rid>/<key>" of a created run → the same address with its wid; anything else is unchanged. */
|
|
223
|
+
function ridToWid(value) { return widOfRid(ledger(), value); }
|
|
224
|
+
/** A run request without a workflow yet (still pending, or rejected), asked about by its rid. */
|
|
225
|
+
function pendingRun(rid) {
|
|
226
|
+
const decision = decisions().get(rid);
|
|
227
|
+
if (decision?.type === "rejected")
|
|
228
|
+
throw new Error(`${rid} is a run request that was rejected: ${decision.reason}`);
|
|
229
|
+
if (pendingRids().has(rid))
|
|
230
|
+
return { submitted: { rid }, state: "pending: no workflow yet; its wid arrives with the next notice" };
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
/** A session's resume found nothing of its own: name the workflows other sessions hold, which need resume wid=<wid>. */
|
|
234
|
+
function resumeElsewhere(reason) {
|
|
235
|
+
if (reason !== "nothing-to-resume")
|
|
236
|
+
return reason;
|
|
237
|
+
const others = pausedElsewhere(home, sender);
|
|
238
|
+
return others.length ? `nothing-to-resume: nothing of this session is paused; paused in other sessions: ${others.slice(0, 8).join(", ")} — resume wid=<wid> continues one` : reason;
|
|
239
|
+
}
|
|
221
240
|
async function submit(args, cwd, signal, wait = true) {
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
// A run answers {submitted:{rid}} when its workflow is not created within 10 s; that rid then stands for the wid.
|
|
242
|
+
for (const field of ["wid", "to", "target"])
|
|
243
|
+
if (typeof args[field] === "string")
|
|
244
|
+
args = { ...args, [field]: ridToWid(args[field]) };
|
|
245
|
+
if (args.action === "status") {
|
|
246
|
+
if (typeof args.wid !== "string" || !args.wid)
|
|
247
|
+
return statusBrief(home, { origin: sender });
|
|
248
|
+
const pending = pendingRun(args.wid);
|
|
249
|
+
if (pending)
|
|
250
|
+
return pending;
|
|
251
|
+
if (typeof args.key === "string" && args.key)
|
|
252
|
+
return statusCallDetail(home, args.wid, args.key);
|
|
253
|
+
return args.full === true ? statusDetail(home, args.wid) : statusCompactDetail(home, args.wid);
|
|
254
|
+
}
|
|
224
255
|
if (args.action === "agents")
|
|
225
256
|
return agentsAt(cwd).map(({ name, description, model, source }) => ({ name, description, ...(model === undefined ? {} : { model }), source }));
|
|
226
257
|
// A send addressed like a stop (target:) means the same call; the field name is not worth a failed round trip.
|
|
@@ -271,7 +302,7 @@ export function registerMain(pi, ui) {
|
|
|
271
302
|
return { wid: receipt.wid };
|
|
272
303
|
// The rid is returned so a later send can supersede this one (replaces: [rid]).
|
|
273
304
|
if (decision.type === "rejected")
|
|
274
|
-
return { applied: false, reason: decision.reason, rid: sent.rid };
|
|
305
|
+
return { applied: false, reason: sent.kind === "resume" && args.wid === undefined ? resumeElsewhere(String(decision.reason)) : decision.reason, rid: sent.rid };
|
|
275
306
|
if (sent.kind !== "run")
|
|
276
307
|
return { applied: true, rid: sent.rid };
|
|
277
308
|
}
|
|
@@ -291,10 +322,10 @@ export function registerMain(pi, ui) {
|
|
|
291
322
|
pi.registerTool(defineTool({
|
|
292
323
|
name: "subagents", label: "Subagents", description: [
|
|
293
324
|
"Durable asynchronous subagents; run returns {wid} when created (or {submitted:{rid}} while pending). A finished workflow (its notice carries every agent's result) or a question wakes you, so after starting work end your turn: never poll with sleep or repeated status. Crash recovery resumes sessions, not external side effects. Background helper processes (orchestrator, evaluator) exit by themselves about 10 s after all work ends: never kill processes or delete files to 'clean up'. When the user quits pi, this session's running workflows pause (nothing is spent); resume continues them.",
|
|
294
|
-
"run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name,
|
|
325
|
+
"run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
|
|
295
326
|
"agents: list names, descriptions, default models and source for this cwd; use these names for run.",
|
|
296
|
-
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model switches at next provider request. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
297
|
-
"stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status
|
|
327
|
+
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model switches at next provider request. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
328
|
+
"stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status: without wid, what runs, asks (with its answer address) or failed, finished workflows one line each; wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
|
|
298
329
|
"Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
|
|
299
330
|
...(agents ? [`Available agents: ${agents}.`] : []),
|
|
300
331
|
"User sees a summary line above the editor; ↓ on an empty editor (or /subagents) opens the list, Enter watches live OR finished calls (finished transcripts remain on disk) and expands finished workflows. List keys: s steer (paste-capable input), x stop (confirm y), m model, a answer when asked, f follow-up on finished calls; action feedback appears in footer.",
|
|
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { callSession, orchInbox } from "../../paths.js";
|
|
5
5
|
import { scanInbox } from "../../kernel/mailbox.js";
|
|
6
6
|
import { CT, JT } from "../../types.js";
|
|
7
|
+
import { presentText } from "../../agent/main/snapshots.js";
|
|
7
8
|
import { rows, script, stack, until } from "./stack.js";
|
|
8
9
|
const tailOf = (path) => { try {
|
|
9
10
|
return readFileSync(path, "utf8").slice(-600);
|
|
@@ -164,7 +165,7 @@ export async function scenario(n, root, env) {
|
|
|
164
165
|
for (const item of message.details?.items ?? []) {
|
|
165
166
|
const resolved = entries.some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev && e.ts <= observation.at) ||
|
|
166
167
|
(item.session && rows(item.session).some(e => e.message?.toolName === "ask" && e.message.details?.qid === item.qid && e.message.details?.rev === item.rev && Date.parse(e.timestamp) <= observation.at));
|
|
167
|
-
check(!resolved || String(message.content).includes(`(resolved: ${item
|
|
168
|
+
check(!resolved || String(message.content).includes(`(resolved: ${presentText(item)})`), `AC4 stale reminder ${item.id}@${item.rev}`);
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
171
|
return { scenario: n, passed: true, duplicateRuns: 0, lostResults: 0, restartedFromScratch: 0, wakes, presentations: attention.length, questions, evidence: root };
|
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)
|
package/dist/compat/spec.js
CHANGED
|
@@ -3,6 +3,8 @@ const fields = new Set(["agent", "task", "model", "cwd", "timeoutMs", "output",
|
|
|
3
3
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
4
4
|
const positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
5
5
|
const text = (v) => typeof v === "string" && v.trim() !== "";
|
|
6
|
+
/** The received value, clipped, so a rejected field says what was sent (e.g. a number sent as a string). */
|
|
7
|
+
const got = (v) => { const s = typeof v === "number" ? String(v) : JSON.stringify(v) ?? String(v); return ` (got ${s.length > 60 ? `${s.slice(0, 60)}…` : s})`; };
|
|
6
8
|
const unknown = (value, known, prefix = "") => Object.keys(value).filter(k => value[k] !== undefined && !known.includes(k)).map(k => `unknown field "${prefix}${k}"`);
|
|
7
9
|
function gate(value) {
|
|
8
10
|
if (typeof value === "string")
|
|
@@ -17,7 +19,7 @@ function gate(value) {
|
|
|
17
19
|
if (value.schema !== undefined)
|
|
18
20
|
errors.push(...schemaProblems(value.schema, "gate.schema"));
|
|
19
21
|
if (value.timeoutMs !== undefined && !positive(value.timeoutMs))
|
|
20
|
-
errors.push(
|
|
22
|
+
errors.push(`gate.timeoutMs must be a positive number${got(value.timeoutMs)}`);
|
|
21
23
|
return errors;
|
|
22
24
|
}
|
|
23
25
|
function budget(value) {
|
|
@@ -28,7 +30,7 @@ function budget(value) {
|
|
|
28
30
|
errors.push("budget needs tokens or costUsd");
|
|
29
31
|
for (const k of ["tokens", "costUsd"])
|
|
30
32
|
if (value[k] !== undefined && !positive(value[k]))
|
|
31
|
-
errors.push(`budget.${k} must be a positive number`);
|
|
33
|
+
errors.push(`budget.${k} must be a positive number${got(value[k])}`);
|
|
32
34
|
return errors;
|
|
33
35
|
}
|
|
34
36
|
/** P34, P24, T2/T3/T4: Validate a call spec at submission; `key` is accepted only for tasks/chain steps. Empty = valid. */
|
|
@@ -45,29 +47,29 @@ export function validateCallSpec(spec, options = {}) {
|
|
|
45
47
|
errors.push("key is only allowed in tasks/chain steps");
|
|
46
48
|
}
|
|
47
49
|
if (!text(s.agent))
|
|
48
|
-
errors.push(
|
|
50
|
+
errors.push(`agent must be a non-empty string${has("agent") ? got(s.agent) : ""}`);
|
|
49
51
|
if (!text(s.task))
|
|
50
52
|
errors.push("task must be a non-empty string");
|
|
51
53
|
for (const k of ["model", "cwd", "output"])
|
|
52
54
|
if (has(k) && typeof s[k] !== "string")
|
|
53
|
-
errors.push(`${k} must be a string`);
|
|
55
|
+
errors.push(`${k} must be a string${got(s[k])}`);
|
|
54
56
|
if (has("timeoutMs") && !positive(s.timeoutMs))
|
|
55
|
-
errors.push(
|
|
57
|
+
errors.push(`timeoutMs must be a positive number (milliseconds)${got(s.timeoutMs)}`);
|
|
56
58
|
if (has("schema"))
|
|
57
59
|
errors.push(...schemaProblems(s.schema, "schema"));
|
|
58
60
|
if (has("gate"))
|
|
59
61
|
errors.push(...gate(s.gate));
|
|
60
62
|
if (has("isolation") && s.isolation !== "none" && s.isolation !== "worktree")
|
|
61
|
-
errors.push(
|
|
63
|
+
errors.push(`isolation must be "none" or "worktree"${got(s.isolation)}`);
|
|
62
64
|
if (has("context") && s.context !== "fresh" && s.context !== "fork")
|
|
63
|
-
errors.push(
|
|
65
|
+
errors.push(`context must be "fresh" or "fork"${got(s.context)}`);
|
|
64
66
|
if (has("budget"))
|
|
65
67
|
errors.push(...budget(s.budget));
|
|
66
68
|
if (has("once") && typeof s.once !== "boolean")
|
|
67
|
-
errors.push(
|
|
69
|
+
errors.push(`once must be a boolean${got(s.once)}`);
|
|
68
70
|
for (const k of ["tools", "skills"])
|
|
69
71
|
if (has(k) && (!Array.isArray(s[k]) || !s[k].every(v => typeof v === "string")))
|
|
70
|
-
errors.push(`${k} must be an array of strings`);
|
|
72
|
+
errors.push(`${k} must be an array of strings${got(s[k])}`);
|
|
71
73
|
if (options.fanout && has("key") && !text(s.key))
|
|
72
74
|
errors.push("key must be a non-empty string");
|
|
73
75
|
return errors;
|
|
@@ -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';
|
|
@@ -22,7 +22,7 @@ import { buildCallResult } from "../../compat/result.js";
|
|
|
22
22
|
import createEffects from "./effects/index.js";
|
|
23
23
|
import { continueSession } from "./generation.js";
|
|
24
24
|
import { hibernation, openQuestion } from "./hibernate.js";
|
|
25
|
-
import { evidence, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
|
|
25
|
+
import { evidence, fatalProviderError, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
|
|
26
26
|
import { activeTotal } from "./time.js";
|
|
27
27
|
import { observeExecution } from "./observe.js";
|
|
28
28
|
import { availableMemory } from "./memory.js";
|
|
@@ -572,6 +572,8 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
572
572
|
return finish(journal, t.callId, exec, { ...makeResult("ok", ev.text), usage: ev.usage });
|
|
573
573
|
if (t.spec.once && dangling.length)
|
|
574
574
|
return finish(journal, t.callId, exec, makeResult("unknown", "", `Unknown tool outcomes: ${dangling.join(", ")}`));
|
|
575
|
+
if (has(journal, "settled", exec) && !ev.text && ev.error && fatalProviderError(ev.error))
|
|
576
|
+
return finish(journal, t.callId, exec, makeResult("failed", "", `Provider error: ${ev.error}`));
|
|
575
577
|
await serial(async () => {
|
|
576
578
|
if (!has(journal, "loss", exec))
|
|
577
579
|
await journal.append("loss", { exec });
|
|
@@ -579,7 +581,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
579
581
|
});
|
|
580
582
|
const losses = journal.entries().filter(e => e.type === "loss" && String(e.exec).startsWith(`${t.callId}#`)).length;
|
|
581
583
|
if (losses >= (config.k?.lossBound ?? 5))
|
|
582
|
-
return finish(journal, t.callId, exec, makeResult("failed", "", `lost ×${losses}`));
|
|
584
|
+
return finish(journal, t.callId, exec, makeResult("failed", "", `lost ×${losses}${ev.error ? `; last error: ${ev.error.slice(0, 300)}` : ""}`));
|
|
583
585
|
await release(exec);
|
|
584
586
|
}
|
|
585
587
|
}
|
|
@@ -15,6 +15,8 @@ import { observation, reached, sessionUsage, totalUsage } from "./usage.js";
|
|
|
15
15
|
export async function observeExecution(d) {
|
|
16
16
|
const { home, config, ticket: t, exec, child, serial } = d;
|
|
17
17
|
const session = callSession(home, t.wid, t.key, t.gen), clock = new ActiveTime(), started = clock.last;
|
|
18
|
+
let progress = started, providerError;
|
|
19
|
+
const tools = new Set();
|
|
18
20
|
const prior = activeTotal(t.journal.entries(), t.callId);
|
|
19
21
|
let size = (await fileStat(session).catch(() => ({ size: 0 }))).size, checkpoint = performance.now();
|
|
20
22
|
let signal;
|
|
@@ -47,7 +49,21 @@ export async function observeExecution(d) {
|
|
|
47
49
|
if (open && fresh)
|
|
48
50
|
await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "activity" });
|
|
49
51
|
else if (!open && !clock.asking && performance.now() - clock.last >= (config.k?.stallMs ?? 600000))
|
|
50
|
-
await t.journal.append(JT.attention, { exec, horizon: clock.last, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text:
|
|
52
|
+
await t.journal.append(JT.attention, { exec, horizon: clock.last, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text: `${t.wid}/${t.key}: no execution activity for ${Math.floor((performance.now() - clock.last) / 60000)}m`, wid: t.wid, call: t.callId } });
|
|
53
|
+
});
|
|
54
|
+
const noProgress = () => serial(async () => {
|
|
55
|
+
const id = `noprogress:${t.callId}`;
|
|
56
|
+
const items = t.journal.entries().filter(e => e.type === JT.attention && e.item.id === id);
|
|
57
|
+
const last = items.at(-1)?.item;
|
|
58
|
+
const open = last && !t.journal.entries().some(e => e.type === JT.attentionResolved && e.id === id && e.rev === last.rev);
|
|
59
|
+
const fresh = items.at(-1)?.exec === exec ? progress > Number(items.at(-1)?.horizon) : progress > started;
|
|
60
|
+
if (open && fresh)
|
|
61
|
+
await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "progress" });
|
|
62
|
+
else if (!open && !clock.asking && !tools.size && performance.now() - progress >= (config.k?.progressMs ?? 600000)) {
|
|
63
|
+
const text = `${t.wid}/${t.key}: running but no progress for ${Math.floor((performance.now() - progress) / 60000)}m (no output tokens or tool results)` +
|
|
64
|
+
(providerError ? `; last provider error: ${providerError.slice(0, 200)}` : "");
|
|
65
|
+
await t.journal.append(JT.attention, { exec, horizon: progress, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text, wid: t.wid, call: t.callId } });
|
|
66
|
+
}
|
|
51
67
|
});
|
|
52
68
|
child.stdin.on("error", () => { });
|
|
53
69
|
// Diagnostics only (never evidence for decisions): keep the first 256 KiB of the child's stderr per call.
|
|
@@ -75,6 +91,19 @@ export async function observeExecution(d) {
|
|
|
75
91
|
// P18: Apply RPC boundaries at receipt, before any in-flight scan can resume.
|
|
76
92
|
// Durable observations remain queued; clock transitions never wait on I/O.
|
|
77
93
|
clock.event(event, performance.now());
|
|
94
|
+
const message = event.message;
|
|
95
|
+
const error = event.type === "auto_retry_start" ? event.errorMessage : event.type === "auto_retry_end" ? event.finalError :
|
|
96
|
+
event.type === "message_end" && message?.stopReason === "error" ? message.errorMessage : undefined;
|
|
97
|
+
if (typeof error === "string" && error)
|
|
98
|
+
providerError = error;
|
|
99
|
+
if (event.type === "tool_execution_start")
|
|
100
|
+
tools.add(String(event.toolCallId ?? ""));
|
|
101
|
+
if (event.type === "tool_execution_end")
|
|
102
|
+
tools.delete(String(event.toolCallId ?? ""));
|
|
103
|
+
// Receipt-time progress is independent of RPC chatter, CPU and session growth; open tools suppress alerts.
|
|
104
|
+
if (event.type === "message_update" || ["tool_execution_start", "tool_execution_update", "tool_execution_end"].includes(String(event.type)) ||
|
|
105
|
+
event.type === "message_end" && message?.stopReason !== "error" && (message?.usage?.output ?? 0) > 0)
|
|
106
|
+
progress = performance.now();
|
|
78
107
|
enqueue(async () => {
|
|
79
108
|
const slim = observation(event);
|
|
80
109
|
if (slim) {
|
|
@@ -86,6 +115,7 @@ export async function observeExecution(d) {
|
|
|
86
115
|
}
|
|
87
116
|
await limits();
|
|
88
117
|
await stall();
|
|
118
|
+
await noProgress();
|
|
89
119
|
if (event.type === "agent_settled") {
|
|
90
120
|
await serial(async () => { if (!has("settled"))
|
|
91
121
|
await t.journal.append("settled", { exec }); });
|
|
@@ -121,6 +151,7 @@ export async function observeExecution(d) {
|
|
|
121
151
|
await d.recordUsage(sessionUsage(entries, t.callId));
|
|
122
152
|
await limits();
|
|
123
153
|
await stall();
|
|
154
|
+
await noProgress();
|
|
124
155
|
if (performance.now() - checkpoint >= (config.k?.checkpointMs ?? 10000)) {
|
|
125
156
|
await saveTime();
|
|
126
157
|
checkpoint = performance.now();
|
|
@@ -103,7 +103,13 @@ export function evidence(entries, exec) {
|
|
|
103
103
|
tools.delete(m.toolCallId);
|
|
104
104
|
}
|
|
105
105
|
const budget = segment.some(e => e.type === "custom" && e.customType === CT.budget && e.data?.exec === exec);
|
|
106
|
-
|
|
106
|
+
const last = segment.findLast(e => e.message?.role === "assistant")?.message;
|
|
107
|
+
const error = last?.stopReason === "error" ? last.errorMessage : undefined;
|
|
108
|
+
return { report, budget, text, error, dangling: [...tools].map(([id, name]) => `${name} (${id})`), usage };
|
|
109
|
+
}
|
|
110
|
+
/** Only explicit quota/payment failures are terminal; rate limits, overload and transport errors still retry. */
|
|
111
|
+
export function fatalProviderError(text) {
|
|
112
|
+
return /\b402\b|insufficient[_ ]?(quota|balance|funds)|quota (exceeded|exhausted)|billing|credit balance|额度|余额|usage limit/i.test(text);
|
|
107
113
|
}
|
|
108
114
|
/** P13, C8: Restore the effective provider from the native session's model changes. */
|
|
109
115
|
export function sessionModel(entries) {
|
|
@@ -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) {
|
|
@@ -300,21 +319,111 @@ function origins(home) {
|
|
|
300
319
|
ids.set(wid, undefined);
|
|
301
320
|
return ids;
|
|
302
321
|
}
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
const
|
|
322
|
+
/** Every workflow with its origin and hold, own session first, then newest first. */
|
|
323
|
+
function snapshots(home, origin) {
|
|
324
|
+
const own = (w) => Number(!!origin && w.origin === 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));
|
|
330
|
+
return { all, ...(since !== undefined ? { since } : {}) };
|
|
331
|
+
}
|
|
332
|
+
/** P25, T10: Compact status of all workflows: own session first, then newest first; finished ones beyond the first `keep` collapse into a count. */
|
|
333
|
+
export function statusView(home, options = {}) {
|
|
334
|
+
const keep = options.keep ?? 10;
|
|
335
|
+
const { all, since } = snapshots(home, options.origin);
|
|
311
336
|
let finished = 0;
|
|
312
|
-
const shown = all.filter(w => !
|
|
337
|
+
const shown = all.filter(w => !settled(w) || ++finished <= keep);
|
|
313
338
|
const hidden = all.length - shown.length;
|
|
314
339
|
const paused = all.filter(w => w.paused).length;
|
|
315
340
|
return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
|
|
316
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)` } : {}) };
|
|
317
342
|
}
|
|
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;
|
|
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`;
|
|
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); };
|
|
348
|
+
/** The latest generation of every key, in first-call order. */
|
|
349
|
+
const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()];
|
|
350
|
+
/** Tool status without a wid: what runs, what waits for an answer and what failed, with finished workflows one line each.
|
|
351
|
+
* The full view (every call's last line and usage) ran to tens of thousands of tokens on a busy home. */
|
|
352
|
+
export function statusBrief(home, options = {}) {
|
|
353
|
+
const keep = options.keep ?? 5, now = options.now ?? Date.now(), origin = options.origin;
|
|
354
|
+
const { all } = snapshots(home, origin);
|
|
355
|
+
const mine = (w) => !origin || w.origin === origin;
|
|
356
|
+
const line = (w) => {
|
|
357
|
+
const p = progressOf(w), notOk = latestCalls(w).filter(c => c.result && !c.result.ok).length;
|
|
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` : "",
|
|
359
|
+
w.endedAt !== undefined ? `ended ${age(now - w.endedAt)} ago` : w.startedAt !== undefined ? `started ${age(now - w.startedAt)} ago` : ""].filter(Boolean).join(" · ");
|
|
360
|
+
};
|
|
361
|
+
const brief = (w) => {
|
|
362
|
+
const p = progressOf(w), open = w.attention.filter(a => a.kind !== "finished");
|
|
363
|
+
const calls = latestCalls(w).filter(c => c.phase !== "sealed" || (c.result && !c.result.ok)).map((c) => {
|
|
364
|
+
const live = c.phase !== "sealed", quiet = c.lastActivity !== undefined ? now - c.lastActivity : undefined;
|
|
365
|
+
return { key: c.key, agent: c.agent, phase: c.phase, ...(c.model ? { model: c.model } : {}),
|
|
366
|
+
...(live && c.startedAt !== undefined ? { for: age(now - c.startedAt) } : {}),
|
|
367
|
+
...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
|
|
368
|
+
...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
|
|
369
|
+
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}) };
|
|
370
|
+
});
|
|
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) }));
|
|
372
|
+
const alerts = open.filter(a => a.kind !== "question").map(a => `${a.kind}${a.call ? ` ${w.wid}/${callKey(a.call)}` : ""}: ${clip(a.text, 200)}`);
|
|
373
|
+
return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}), ...(w.followUps ? { followUps: w.followUps } : {}),
|
|
374
|
+
progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
|
|
375
|
+
...(asking.length ? { asking } : {}), ...(alerts.length ? { alerts } : {}) };
|
|
376
|
+
};
|
|
377
|
+
const active = all.filter(w => !settled(w));
|
|
378
|
+
const finished = all.filter(w => settled(w) && mine(w));
|
|
379
|
+
const others = active.filter(w => !mine(w));
|
|
380
|
+
const heldMine = active.filter(w => mine(w) && w.paused).length, heldOthers = others.filter(w => w.paused);
|
|
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"}` : "",
|
|
382
|
+
heldOthers.length ? `${heldOthers.length} workflow${heldOthers.length > 1 ? "s" : ""} of other sessions paused (${heldOthers.slice(0, 8).map(w => w.wid).join(", ")}); resume wid=<wid> continues one` : ""].filter(Boolean).join("; ");
|
|
383
|
+
return {
|
|
384
|
+
active: active.filter(mine).map(brief), ...(others.length ? { otherSessions: others.slice(0, 10).map(line) } : {}),
|
|
385
|
+
finished: finished.slice(0, keep).map(line), ...(finished.length > keep ? { olderFinished: finished.length - keep } : {}),
|
|
386
|
+
...(paused ? { paused } : {}),
|
|
387
|
+
hint: "status wid=<wid> shows one workflow (outputs clipped); add key=<key> for one call's full result, or full:true for everything",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** Running workflows of other sessions that a pause holds, as "wid" or "wid (name)": a session's own resume skips them. */
|
|
391
|
+
export function pausedElsewhere(home, 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);
|
|
393
|
+
}
|
|
394
|
+
/** "<rid>" or "<rid>/<rest>" of a run request that created a workflow → the same address with its wid; else unchanged.
|
|
395
|
+
* A run replies {submitted:{rid}} when its workflow is not created within 10 s, so the rid is all the caller has. */
|
|
396
|
+
export function widOfRid(ledger, value) {
|
|
397
|
+
const cut = value.indexOf("/"), head = cut < 0 ? value : value.slice(0, cut);
|
|
398
|
+
const created = head ? ledger.find(e => e.type === JT.created && e.rid === head) : undefined;
|
|
399
|
+
return created ? `${String(created.wid)}${value.slice(head.length)}` : value;
|
|
400
|
+
}
|
|
401
|
+
const OUTPUT = 600;
|
|
402
|
+
/** Tool status with a wid: one workflow with each call's output clipped (the full detail repeated every output twice and
|
|
403
|
+
* reached ~100K characters); `key` gives one call in full. */
|
|
404
|
+
export function statusCompactDetail(home, wid) {
|
|
405
|
+
const detail = statusDetail(home, wid), compact = compactWorkflow(detail);
|
|
406
|
+
const byId = new Map(detail.calls.map(c => [c.callId, c]));
|
|
407
|
+
const calls = compact.calls.map(({ lastLine: _l, ...c }) => {
|
|
408
|
+
const full = byId.get(c.callId), output = full.result?.output?.trim();
|
|
409
|
+
return { ...c, agent: full.agent, ...(output ? { output: output.length > OUTPUT ? `${output.slice(0, OUTPUT)}… [${output.length - OUTPUT} more chars: status wid key=${c.key}]` : output } : {}) };
|
|
410
|
+
});
|
|
411
|
+
let result;
|
|
412
|
+
if (detail.result !== undefined) {
|
|
413
|
+
const json = JSON.stringify(detail.result) ?? "";
|
|
414
|
+
result = json.length <= 2000 ? detail.result : `${json.slice(0, 2000)}… [${json.length - 2000} more chars: status wid full:true]`;
|
|
415
|
+
}
|
|
416
|
+
return { ...compact, ...(detail.cwd ? { cwd: detail.cwd } : {}), ...(detail.scriptLog ? { scriptLog: detail.scriptLog } : {}),
|
|
417
|
+
...(result !== undefined ? { result } : {}), calls, hint: "key=<key> gives one call's full result; full:true gives everything" };
|
|
418
|
+
}
|
|
419
|
+
/** Tool status with a wid and key: that call's latest generation in full (result, usage, sends). */
|
|
420
|
+
export function statusCallDetail(home, wid, key) {
|
|
421
|
+
const detail = statusDetail(home, wid);
|
|
422
|
+
const call = detail.calls.findLast(c => c.key === key || c.callId === key || `${c.key}@${c.gen}` === key);
|
|
423
|
+
if (!call)
|
|
424
|
+
throw new Error(`No call "${key}" in ${wid}; calls: ${[...new Set(detail.calls.map(c => c.key))].join(", ")}`);
|
|
425
|
+
return { wid, ...call };
|
|
426
|
+
}
|
|
318
427
|
/** P25, T10: One workflow in full detail (results, outputs, script log path) but without raw journal entries. */
|
|
319
428
|
export function statusDetail(home, wid) {
|
|
320
429
|
const origin = origins(home);
|
package/dist/ui/cards.js
CHANGED
|
@@ -61,7 +61,8 @@ export function registerCards(pi, home) {
|
|
|
61
61
|
let last;
|
|
62
62
|
const draw = (width) => items.flatMap(item => {
|
|
63
63
|
const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
|
|
64
|
-
const
|
|
64
|
+
const title = item.kind === "stall" && item.id.startsWith("noprogress:") ? "no progress" : h.title;
|
|
65
|
+
const heading = `${h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? "— answered" : title}`;
|
|
65
66
|
// v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
|
|
66
67
|
const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
|
|
67
68
|
const body = item.kind === "finished" && !closed
|
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
|
@@ -30,6 +30,10 @@ export function callLine(args) {
|
|
|
30
30
|
/** UI §5: The collapsed result: started / applied / rejected, or one line per workflow for status. */
|
|
31
31
|
export function resultLines(details) {
|
|
32
32
|
const d = (details ?? {});
|
|
33
|
+
if (typeof d.wid === "string" && typeof d.key === "string" && typeof d.phase === "string") {
|
|
34
|
+
const r = d.result;
|
|
35
|
+
return [`${d.key} · ${r?.status ?? d.phase}${typeof d.model === "string" ? ` · ${d.model}` : ""}`];
|
|
36
|
+
}
|
|
33
37
|
if (typeof d.wid === "string" && !Array.isArray(d.calls))
|
|
34
38
|
return [`started workflow ${d.wid}`, ...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : [])];
|
|
35
39
|
if (d.applied === true)
|
|
@@ -43,6 +47,15 @@ export function resultLines(details) {
|
|
|
43
47
|
const asks = (w.attention ?? []).filter(a => a.kind === "question").length;
|
|
44
48
|
return `${w.name ?? short(w.wid)} · ${w.status} · ${done}/${w.calls.length} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}`;
|
|
45
49
|
};
|
|
50
|
+
if (Array.isArray(d.active)) {
|
|
51
|
+
const rows = d.active.map(w => {
|
|
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.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
|
+
});
|
|
55
|
+
const finished = Array.isArray(d.finished) ? d.finished.length + Number(d.olderFinished ?? 0) : 0;
|
|
56
|
+
return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["nothing running"]),
|
|
57
|
+
...(rows.length > 6 ? [`… ${rows.length - 6} more`] : []), ...(finished ? [`${finished} finished`] : [])];
|
|
58
|
+
}
|
|
46
59
|
if (Array.isArray(d.workflows)) {
|
|
47
60
|
const rows = d.workflows.map(row);
|
|
48
61
|
return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["no workflows"]), ...(rows.length > 6 ? [`… ${rows.length - 6} more`] : [])];
|
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) {
|
|
@@ -72,8 +72,8 @@ export function plainReason(reason) {
|
|
|
72
72
|
return `that subagent already finished${detail}`;
|
|
73
73
|
if (code === "finished")
|
|
74
74
|
return `that subagent already finished${detail}; use f to follow up`;
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
75
|
+
if (code === "nothing-to-resume")
|
|
76
|
+
return `nothing of this session is paused, so there is nothing to resume${r.includes("other sessions") ? `; ${r.slice(r.indexOf("paused in other sessions")).split(" — ")[0]}` : ""}`;
|
|
77
77
|
if (code === "not-parked")
|
|
78
78
|
return "it is already running";
|
|
79
79
|
if (r === "already-answered" || r === "stale-question")
|
|
@@ -117,6 +117,10 @@ export function statusPhrase(call, workflow, facts, now) {
|
|
|
117
117
|
const question = workflow.attention.find(a => a.kind === "question" && a.call === call.callId);
|
|
118
118
|
if (question)
|
|
119
119
|
return `asking main agent: ${question.text}`;
|
|
120
|
+
const noProgress = workflow.attention.find(a => a.kind === "stall" && a.call === call.callId && a.id.startsWith("noprogress:"));
|
|
121
|
+
// The alert's duration measures progress, whereas lastActivity also advances on provider retries.
|
|
122
|
+
if (noProgress)
|
|
123
|
+
return /no progress for [^(;]+/.exec(noProgress.text)?.[0].trim() ?? "no progress";
|
|
120
124
|
if (workflow.attention.some(a => a.kind === "stall" && a.call === call.callId))
|
|
121
125
|
return `no activity for ${duration(now - Math.max(call.lastActivity ?? call.startedAt ?? now, facts?.lastActivity ?? 0))}`;
|
|
122
126
|
if (call.phase === "queued")
|
|
@@ -142,18 +146,18 @@ export const resultWord = (status) => WORDS[status] ?? "failed";
|
|
|
142
146
|
const WORD_ORDER = ["done", "stopped", "failed", "timeout", "budget", "unknown", "skipped", "parked"];
|
|
143
147
|
/** UI §1,4, v12 §4: One working sentence; done/total uses planned totals, `n+` while a script workflow keeps proposing. */
|
|
144
148
|
export function summary(workflows) {
|
|
145
|
-
const running = workflows.filter(
|
|
146
|
-
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);
|
|
147
151
|
const paused = running.filter(w => w.paused).reduce((n, w) => n + open(w), 0); // held work is not working: it burns nothing
|
|
148
152
|
// Waiting for a slot is not working either: nothing is spent until it launches.
|
|
149
|
-
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);
|
|
150
154
|
const progress = running.map(progressOf);
|
|
151
155
|
return { working: running.reduce((n, w) => n + open(w), 0) - asking - paused - queued, asking, queued, paused,
|
|
152
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) };
|
|
153
157
|
}
|
|
154
158
|
/** UI §1, v12 §4: What needs you first, then what is running, then progress — e.g. "1 asking · 3 working · 12/40 done". */
|
|
155
159
|
export function summaryText(workflows) {
|
|
156
|
-
const s = summary(workflows), finished = workflows.filter(w => w
|
|
160
|
+
const s = summary(workflows), finished = workflows.filter(w => !isLive(w)).length;
|
|
157
161
|
if (!s.total)
|
|
158
162
|
return finished ? `${finished} finished` : "nothing running";
|
|
159
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(" · ");
|
|
@@ -165,7 +169,7 @@ export function mainLine(workflows) {
|
|
|
165
169
|
// The key that opens the list is spelled out: a bare arrow is easy to miss.
|
|
166
170
|
if (s.working || s.asking || s.queued || s.paused)
|
|
167
171
|
return `${summaryText(workflows)} · ↓ subagents`;
|
|
168
|
-
const w = workflows.filter(w => w
|
|
172
|
+
const w = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
|
|
169
173
|
if (!w)
|
|
170
174
|
return undefined;
|
|
171
175
|
const latest = [...new Map(w.calls.map(c => [c.key, c])).values()]; // the newest generation of each key (P37)
|
|
@@ -184,15 +188,16 @@ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
|
|
|
184
188
|
* then one summary line; afterwards only the completion sentence for ten minutes; otherwise nothing. Activity spins
|
|
185
189
|
* only while there is fresh evidence, so a quiet agent visibly stops moving. */
|
|
186
190
|
export function dockLines(workflows, facts, name, width, now = Date.now(), rows = 3) {
|
|
187
|
-
const live = workflows.filter(
|
|
191
|
+
const live = workflows.filter(isLive);
|
|
188
192
|
const active = live.flatMap(w => {
|
|
189
193
|
const latest = new Map();
|
|
190
194
|
for (const c of w.calls)
|
|
191
195
|
latest.set(c.key, c);
|
|
192
|
-
|
|
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) }));
|
|
193
198
|
}).sort((a, b) => Number(b.asking) - Number(a.asking));
|
|
194
199
|
if (!active.length) {
|
|
195
|
-
const ended = workflows.filter(w => w
|
|
200
|
+
const ended = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
|
|
196
201
|
const line = ended && now - (ended.endedAt ?? 0) < 10 * 60_000 ? mainLine(workflows) : undefined;
|
|
197
202
|
return line ? [truncateToWidth(line, Math.max(1, width))] : [];
|
|
198
203
|
}
|
|
@@ -213,7 +218,7 @@ export function dockLines(workflows, facts, name, width, now = Date.now(), rows
|
|
|
213
218
|
* keep unviewed failures visible, and page newest done rows eight at a time. */
|
|
214
219
|
/** v12 §5: Whether a workflow shows its agents: running ones unless folded; finished ones once opened (Enter, or on completion). */
|
|
215
220
|
export function isOpen(w, state) {
|
|
216
|
-
return !state.folded.has(w.wid) && (w
|
|
221
|
+
return !state.folded.has(w.wid) && (isLive(w) || (state.done.get(w.wid) ?? 0) > 0);
|
|
217
222
|
}
|
|
218
223
|
/** v12 §5: Enter on a workflow row opens or closes it. */
|
|
219
224
|
export function toggleOpen(w, state) {
|
|
@@ -221,7 +226,7 @@ export function toggleOpen(w, state) {
|
|
|
221
226
|
state.folded.add(w.wid);
|
|
222
227
|
else {
|
|
223
228
|
state.folded.delete(w.wid);
|
|
224
|
-
if (w
|
|
229
|
+
if (!isLive(w))
|
|
225
230
|
state.done.set(w.wid, Math.max(8, state.done.get(w.wid) ?? 0));
|
|
226
231
|
}
|
|
227
232
|
}
|
|
@@ -263,13 +268,13 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
|
|
|
263
268
|
for (const c of v.calls)
|
|
264
269
|
latest.set(c.key, c);
|
|
265
270
|
const w = latest.size === v.calls.length ? v : { ...v, calls: v.calls.filter(c => latest.get(c.key) === c) };
|
|
266
|
-
const dim = w
|
|
271
|
+
const dim = !isLive(w); // v12 §5: finished workflows are dimmed, not hidden; an open follow-up keeps one live
|
|
267
272
|
if (w.calls.length === 1) {
|
|
268
273
|
callRow(w, w.calls[0], " ", " ", dim);
|
|
269
274
|
continue;
|
|
270
275
|
}
|
|
271
276
|
// Stable order (UI §2): active rows keep proposal (snapshot) order; done rows by immutable end time.
|
|
272
|
-
const done = doneOrder(w.calls), active = w
|
|
277
|
+
const done = doneOrder(w.calls), active = liveCalls(w), folded = !isOpen(w, state);
|
|
273
278
|
const progress = progressOf(w); // v12 §4: done/planned, `n+` while a script workflow keeps proposing
|
|
274
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))}` });
|
|
275
280
|
if (folded)
|
|
@@ -281,7 +286,7 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
|
|
|
281
286
|
active.forEach((c, i) => { const last = i === active.length - 1 && !done.length; callRow(w, c, last ? " └ " : " ├ ", last ? " " : " │ ", dim); });
|
|
282
287
|
if (!done.length)
|
|
283
288
|
continue;
|
|
284
|
-
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);
|
|
285
290
|
rows.push({ id: `${w.wid}:done`, kind: "done", workflow: w, dim, text: ` └ ${count ? "▾" : "▸"} ${progress.done} done` });
|
|
286
291
|
const more = count && done.length > count;
|
|
287
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",
|