pi-durable-subagents 1.0.3 → 1.0.5
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 +37 -0
- package/README.md +11 -1
- package/dist/agent/main/snapshots.js +41 -14
- package/dist/agent/main/tool.js +19 -1
- package/dist/agent/main.js +43 -12
- package/dist/cli/chaos/scenario.js +2 -1
- package/dist/compat/spec.js +11 -9
- 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 +98 -4
- package/dist/ui/cards.js +32 -11
- package/dist/ui/data.js +80 -20
- package/dist/ui/session.js +16 -1
- package/dist/ui/tool.js +13 -0
- package/dist/ui/view.js +6 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.5
|
|
4
|
+
|
|
5
|
+
- `status` without a wid is brief: what runs, what asks (with the address to
|
|
6
|
+
answer) and what failed, with finished workflows one line each; it used to
|
|
7
|
+
return every call's usage and last line (tens of thousands of tokens on a
|
|
8
|
+
busy home). With a wid, outputs are clipped; `key` gives one call's full
|
|
9
|
+
result and `full: true` the previous complete detail.
|
|
10
|
+
- A run's rid (from `{submitted: {rid}}`) works wherever a wid is expected.
|
|
11
|
+
- With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`,
|
|
12
|
+
`isolation`, `context`, `tools`, `skills` and `once` are defaults for every
|
|
13
|
+
step; other fields there, or any call field beside a workflow script, are
|
|
14
|
+
refused instead of silently ignored. Invalid fields report the value
|
|
15
|
+
received.
|
|
16
|
+
- A call that keeps running without producing output tokens or tool results
|
|
17
|
+
(for example, retrying against an exhausted provider) raises a
|
|
18
|
+
"no progress" alert after 10 minutes (`k.progressMs`), with the last
|
|
19
|
+
provider error. A call that ends on an explicit quota or billing error fails
|
|
20
|
+
at once with `Provider error: …` instead of being relaunched; other lost
|
|
21
|
+
executions report their last error.
|
|
22
|
+
- Attention reaches the main agent with the call's address; a question says
|
|
23
|
+
how to answer it.
|
|
24
|
+
- `resume` without a wid names workflows that other sessions hold paused.
|
|
25
|
+
|
|
26
|
+
## 1.0.4
|
|
27
|
+
|
|
28
|
+
- The TUI no longer stalls pi with a long subagent history. Its 500 ms refresh
|
|
29
|
+
re-read and re-derived every historical call's session and scanned journals
|
|
30
|
+
once per call, taking over a second per refresh with a few hundred finished
|
|
31
|
+
calls; it now reuses per-call facts and a journal index (about 5 ms).
|
|
32
|
+
- Main-session polling, attention cards and transcript cards reuse derived
|
|
33
|
+
state per journal and per width instead of rescanning the session every
|
|
34
|
+
200 ms, so typing stays responsive in long sessions.
|
|
35
|
+
- Faster startup: thinking summaries no longer use a regex that went
|
|
36
|
+
quadratic on long unpunctuated thoughts (200 KB took 43 s), and finished
|
|
37
|
+
calls' sessions are read when first shown or in small idle slices rather
|
|
38
|
+
than all at startup.
|
|
39
|
+
|
|
3
40
|
## 1.0.3
|
|
4
41
|
|
|
5
42
|
- Recovery delivers a call's task when an earlier execution ended before its
|
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. |
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readJournalSnapshot } from "../../kernel/journal.js";
|
|
4
|
-
import { singlePresentation } from "../../kernel/guards.js";
|
|
5
4
|
import { journalPath, orchLedger } from "../../paths.js";
|
|
6
5
|
import { CT, JT } from "../../types.js";
|
|
7
6
|
import { holdOf } from "../../orchestrator/snapshot.js";
|
|
@@ -20,9 +19,13 @@ export function workflows(home) {
|
|
|
20
19
|
}
|
|
21
20
|
const pruned = new Set(ledger.filter(e => e.type === "pruned").map(e => String(e.wid)));
|
|
22
21
|
const ids = new Set([...ledger.filter(e => e.type === JT.created).map(e => String(e.wid)), ...names].filter(wid => !pruned.has(wid)));
|
|
22
|
+
const createdBy = new Map();
|
|
23
|
+
for (const e of ledger)
|
|
24
|
+
if (e.type === JT.created && !createdBy.has(e.wid))
|
|
25
|
+
createdBy.set(e.wid, e);
|
|
23
26
|
return [...ids].sort().map(wid => {
|
|
24
27
|
const entries = readJournalSnapshot(journalPath(home, wid));
|
|
25
|
-
const created =
|
|
28
|
+
const created = createdBy.get(wid) ?? entries.find(e => e.type === JT.created);
|
|
26
29
|
return { wid, origin: created?.origin, entries };
|
|
27
30
|
});
|
|
28
31
|
}
|
|
@@ -36,29 +39,53 @@ export function unfinishedWorkflow(home) {
|
|
|
36
39
|
const ledger = readJournalSnapshot(orchLedger(home));
|
|
37
40
|
return workflows(home).some(w => (!w.entries.some(e => e.type === JT.done) || openGeneration(w.entries)) && !holdOf(ledger, w.wid, w.origin));
|
|
38
41
|
}
|
|
39
|
-
|
|
42
|
+
const receipts = new WeakMap();
|
|
43
|
+
const receiptsOf = (entry) => entry.type === "custom_message" && entry.customType === CT.attention
|
|
44
|
+
? entry.details?.items ?? [] : [];
|
|
40
45
|
export function presented(ctx) {
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
// The main session is append-only between session switches: scan only the entries added since the last poll.
|
|
47
|
+
const entries = ctx.sessionManager.getEntries();
|
|
48
|
+
const owner = ctx.sessionManager, prior = receipts.get(owner);
|
|
49
|
+
const appended = prior && prior.length <= entries.length && prior.first === entries[0] && prior.last === entries[prior.length - 1];
|
|
50
|
+
const items = appended ? prior.items.concat(entries.slice(prior.length).flatMap(receiptsOf)) : entries.flatMap(receiptsOf);
|
|
51
|
+
receipts.set(owner, { first: entries[0], last: entries.at(-1), length: entries.length, items });
|
|
52
|
+
return items;
|
|
53
|
+
}
|
|
54
|
+
const open = new WeakMap();
|
|
55
|
+
/** The unresolved attention items of one immutable journal snapshot, computed once per snapshot. */
|
|
56
|
+
function unresolved(entries) {
|
|
57
|
+
let items = open.get(entries);
|
|
58
|
+
if (items)
|
|
59
|
+
return items;
|
|
60
|
+
const done = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}\0${e.rev}`));
|
|
61
|
+
items = entries.filter(e => e.type === JT.attention).map(e => e.item).filter(item => !done.has(`${item.id}\0${item.rev}`));
|
|
62
|
+
open.set(entries, items);
|
|
63
|
+
return items;
|
|
43
64
|
}
|
|
44
65
|
/** P15, V7: Select only unresolved, unpresented items owned by this session. */
|
|
45
66
|
export function attention(home, sender, seen) {
|
|
46
|
-
const items = [];
|
|
67
|
+
const items = [], shown = new Set(seen.map(s => `${s.id}\0${s.rev}`));
|
|
47
68
|
for (const workflow of workflows(home)) {
|
|
48
69
|
if (workflow.origin !== sender)
|
|
49
70
|
continue;
|
|
50
|
-
for (const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (singlePresentation({ presented: [...seen, ...items] }, item))
|
|
57
|
-
items.push(item);
|
|
71
|
+
for (const item of unresolved(workflow.entries)) {
|
|
72
|
+
const key = `${item.id}\0${item.rev}`;
|
|
73
|
+
if (shown.has(key))
|
|
74
|
+
continue; // singlePresentation: one presentation per id/rev
|
|
75
|
+
shown.add(key);
|
|
76
|
+
items.push(item);
|
|
58
77
|
}
|
|
59
78
|
}
|
|
60
79
|
return items;
|
|
61
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
|
+
}
|
|
62
89
|
/** P15: Refresh a question against durable workflow and child receipts at request time. */
|
|
63
90
|
export function resolved(home, item) {
|
|
64
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 { 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())
|
|
@@ -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/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;
|
|
@@ -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) {
|
|
@@ -214,8 +214,14 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
216
|
/** P25: Snapshot one workflow from its durable journal (v12 §4: plus the planned total of its pinned run body). */
|
|
217
|
+
// A journal snapshot is immutable and replaced on every append, so its derived workflow snapshot is reused until then:
|
|
218
|
+
// re-deriving every historical workflow on each UI refresh dominated pi's main thread.
|
|
219
|
+
const derived = new WeakMap();
|
|
217
220
|
export function workflowSnapshot(home, wid) {
|
|
218
|
-
const
|
|
221
|
+
const entries = readJournalSnapshot(journalPath(home, wid)), hit = derived.get(entries);
|
|
222
|
+
const wf = hit?.wid === wid ? hit.snapshot : snapshotFromEntries(wid, entries);
|
|
223
|
+
if (hit?.wid !== wid && entries.length)
|
|
224
|
+
derived.set(entries, { wid, snapshot: wf });
|
|
219
225
|
const planned = plannedTotal(home, wid, wf.rev);
|
|
220
226
|
return planned === undefined ? wf : { ...wf, planned };
|
|
221
227
|
}
|
|
@@ -294,14 +300,20 @@ function origins(home) {
|
|
|
294
300
|
ids.set(wid, undefined);
|
|
295
301
|
return ids;
|
|
296
302
|
}
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
const
|
|
303
|
+
/** Every workflow with its origin and hold, own session first, then newest first. */
|
|
304
|
+
function snapshots(home, origin) {
|
|
305
|
+
const own = (w) => Number(!!origin && w.origin === origin);
|
|
300
306
|
const { since, held } = heldWorkflows(home);
|
|
301
307
|
const all = [...origins(home)].sort(([a], [b]) => a < b ? 1 : a > b ? -1 : 0).map(([wid, origin]) => {
|
|
302
308
|
const wf = workflowSnapshot(home, wid), withOrigin = wf.origin === undefined && origin !== undefined ? { ...wf, origin } : wf;
|
|
303
309
|
return withOrigin.status === "running" && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
|
|
304
310
|
}).sort((a, b) => own(b) - own(a));
|
|
311
|
+
return { all, ...(since !== undefined ? { since } : {}) };
|
|
312
|
+
}
|
|
313
|
+
/** P25, T10: Compact status of all workflows: own session first, then newest first; finished ones beyond the first `keep` collapse into a count. */
|
|
314
|
+
export function statusView(home, options = {}) {
|
|
315
|
+
const keep = options.keep ?? 10;
|
|
316
|
+
const { all, since } = snapshots(home, options.origin);
|
|
305
317
|
let finished = 0;
|
|
306
318
|
const shown = all.filter(w => !["done", "failed", "stopped"].includes(w.status) || ++finished <= keep);
|
|
307
319
|
const hidden = all.length - shown.length;
|
|
@@ -309,6 +321,88 @@ export function statusView(home, options = {}) {
|
|
|
309
321
|
return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
|
|
310
322
|
...(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)` } : {}) };
|
|
311
323
|
}
|
|
324
|
+
const FINAL = ["done", "failed", "stopped"];
|
|
325
|
+
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
|
+
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
|
+
/** The latest generation of every key, in first-call order. */
|
|
328
|
+
const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()];
|
|
329
|
+
/** Tool status without a wid: what runs, what waits for an answer and what failed, with finished workflows one line each.
|
|
330
|
+
* The full view (every call's last line and usage) ran to tens of thousands of tokens on a busy home. */
|
|
331
|
+
export function statusBrief(home, options = {}) {
|
|
332
|
+
const keep = options.keep ?? 5, now = options.now ?? Date.now(), origin = options.origin;
|
|
333
|
+
const { all } = snapshots(home, origin);
|
|
334
|
+
const mine = (w) => !origin || w.origin === origin;
|
|
335
|
+
const line = (w) => {
|
|
336
|
+
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` : "",
|
|
338
|
+
w.endedAt !== undefined ? `ended ${age(now - w.endedAt)} ago` : w.startedAt !== undefined ? `started ${age(now - w.startedAt)} ago` : ""].filter(Boolean).join(" · ");
|
|
339
|
+
};
|
|
340
|
+
const brief = (w) => {
|
|
341
|
+
const p = progressOf(w), open = w.attention.filter(a => a.kind !== "finished");
|
|
342
|
+
const calls = latestCalls(w).filter(c => c.phase !== "sealed" || (c.result && !c.result.ok)).map((c) => {
|
|
343
|
+
const live = c.phase !== "sealed", quiet = c.lastActivity !== undefined ? now - c.lastActivity : undefined;
|
|
344
|
+
return { key: c.key, agent: c.agent, phase: c.phase, ...(c.model ? { model: c.model } : {}),
|
|
345
|
+
...(live && c.startedAt !== undefined ? { for: age(now - c.startedAt) } : {}),
|
|
346
|
+
...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
|
|
347
|
+
...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
|
|
348
|
+
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}) };
|
|
349
|
+
});
|
|
350
|
+
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
|
+
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 } : {}),
|
|
353
|
+
progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
|
|
354
|
+
...(asking.length ? { asking } : {}), ...(alerts.length ? { alerts } : {}) };
|
|
355
|
+
};
|
|
356
|
+
const active = all.filter(w => !FINAL.includes(w.status));
|
|
357
|
+
const finished = all.filter(w => FINAL.includes(w.status) && mine(w));
|
|
358
|
+
const others = active.filter(w => !mine(w));
|
|
359
|
+
const heldMine = active.filter(w => mine(w) && w.paused).length, heldOthers = others.filter(w => w.paused);
|
|
360
|
+
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"}` : "",
|
|
361
|
+
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("; ");
|
|
362
|
+
return {
|
|
363
|
+
active: active.filter(mine).map(brief), ...(others.length ? { otherSessions: others.slice(0, 10).map(line) } : {}),
|
|
364
|
+
finished: finished.slice(0, keep).map(line), ...(finished.length > keep ? { olderFinished: finished.length - keep } : {}),
|
|
365
|
+
...(paused ? { paused } : {}),
|
|
366
|
+
hint: "status wid=<wid> shows one workflow (outputs clipped); add key=<key> for one call's full result, or full:true for everything",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/** Running workflows of other sessions that a pause holds, as "wid" or "wid (name)": a session's own resume skips them. */
|
|
370
|
+
export function pausedElsewhere(home, origin) {
|
|
371
|
+
return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !FINAL.includes(w.status)).map(w => w.name ? `${w.wid} (${w.name})` : w.wid);
|
|
372
|
+
}
|
|
373
|
+
/** "<rid>" or "<rid>/<rest>" of a run request that created a workflow → the same address with its wid; else unchanged.
|
|
374
|
+
* A run replies {submitted:{rid}} when its workflow is not created within 10 s, so the rid is all the caller has. */
|
|
375
|
+
export function widOfRid(ledger, value) {
|
|
376
|
+
const cut = value.indexOf("/"), head = cut < 0 ? value : value.slice(0, cut);
|
|
377
|
+
const created = head ? ledger.find(e => e.type === JT.created && e.rid === head) : undefined;
|
|
378
|
+
return created ? `${String(created.wid)}${value.slice(head.length)}` : value;
|
|
379
|
+
}
|
|
380
|
+
const OUTPUT = 600;
|
|
381
|
+
/** Tool status with a wid: one workflow with each call's output clipped (the full detail repeated every output twice and
|
|
382
|
+
* reached ~100K characters); `key` gives one call in full. */
|
|
383
|
+
export function statusCompactDetail(home, wid) {
|
|
384
|
+
const detail = statusDetail(home, wid), compact = compactWorkflow(detail);
|
|
385
|
+
const byId = new Map(detail.calls.map(c => [c.callId, c]));
|
|
386
|
+
const calls = compact.calls.map(({ lastLine: _l, ...c }) => {
|
|
387
|
+
const full = byId.get(c.callId), output = full.result?.output?.trim();
|
|
388
|
+
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 } : {}) };
|
|
389
|
+
});
|
|
390
|
+
let result;
|
|
391
|
+
if (detail.result !== undefined) {
|
|
392
|
+
const json = JSON.stringify(detail.result) ?? "";
|
|
393
|
+
result = json.length <= 2000 ? detail.result : `${json.slice(0, 2000)}… [${json.length - 2000} more chars: status wid full:true]`;
|
|
394
|
+
}
|
|
395
|
+
return { ...compact, ...(detail.cwd ? { cwd: detail.cwd } : {}), ...(detail.scriptLog ? { scriptLog: detail.scriptLog } : {}),
|
|
396
|
+
...(result !== undefined ? { result } : {}), calls, hint: "key=<key> gives one call's full result; full:true gives everything" };
|
|
397
|
+
}
|
|
398
|
+
/** Tool status with a wid and key: that call's latest generation in full (result, usage, sends). */
|
|
399
|
+
export function statusCallDetail(home, wid, key) {
|
|
400
|
+
const detail = statusDetail(home, wid);
|
|
401
|
+
const call = detail.calls.findLast(c => c.key === key || c.callId === key || `${c.key}@${c.gen}` === key);
|
|
402
|
+
if (!call)
|
|
403
|
+
throw new Error(`No call "${key}" in ${wid}; calls: ${[...new Set(detail.calls.map(c => c.key))].join(", ")}`);
|
|
404
|
+
return { wid, ...call };
|
|
405
|
+
}
|
|
312
406
|
/** P25, T10: One workflow in full detail (results, outputs, script log path) but without raw journal entries. */
|
|
313
407
|
export function statusDetail(home, wid) {
|
|
314
408
|
const origin = origins(home);
|
package/dist/ui/cards.js
CHANGED
|
@@ -33,13 +33,20 @@ export function card(theme, tone, heading, body, width, expanded = false) {
|
|
|
33
33
|
/** P15, P16: Register renderers for attention presentations and watch-view notes in the main session. */
|
|
34
34
|
export function registerCards(pi, home) {
|
|
35
35
|
const done = new Set(); // resolution is monotone: once resolved, never re-read
|
|
36
|
+
// pi renders every visible message on each frame; an open question re-reads its child session at most once a second.
|
|
37
|
+
const checked = new Map();
|
|
36
38
|
const isResolved = (item) => {
|
|
37
39
|
const id = `${item.id}@${item.rev}`;
|
|
38
40
|
if (done.has(id))
|
|
39
41
|
return true;
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
if (now - (checked.get(id) ?? -Infinity) < 1000)
|
|
44
|
+
return false;
|
|
45
|
+
checked.set(id, now);
|
|
40
46
|
try {
|
|
41
47
|
if (resolved(home, item)) {
|
|
42
48
|
done.add(id);
|
|
49
|
+
checked.delete(id);
|
|
43
50
|
return true;
|
|
44
51
|
}
|
|
45
52
|
}
|
|
@@ -50,19 +57,33 @@ export function registerCards(pi, home) {
|
|
|
50
57
|
const items = message.details?.items;
|
|
51
58
|
if (!items?.length)
|
|
52
59
|
return undefined;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// The lines depend only on width, expansion, theme and which questions are answered: reuse them across frames.
|
|
61
|
+
let last;
|
|
62
|
+
const draw = (width) => items.flatMap(item => {
|
|
63
|
+
const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
|
|
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}`;
|
|
66
|
+
// v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
|
|
67
|
+
const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
|
|
68
|
+
const body = item.kind === "finished" && !closed
|
|
69
|
+
? digestLines(item.text, line => theme.fg("dim", line), inner)
|
|
70
|
+
: [closed ? theme.fg("dim", item.text) : item.text];
|
|
71
|
+
return card(theme, closed ? "muted" : h.tone, heading, body, width, options.expanded);
|
|
72
|
+
});
|
|
73
|
+
return { invalidate() { last = undefined; }, render: (width) => {
|
|
74
|
+
const key = `${width}|${items.map(item => item.kind === "question" && isResolved(item) ? 1 : 0).join("")}`;
|
|
75
|
+
if (last?.key !== key)
|
|
76
|
+
last = { key, lines: draw(width) };
|
|
77
|
+
return last.lines;
|
|
78
|
+
} };
|
|
63
79
|
});
|
|
64
80
|
pi.registerMessageRenderer(CT.note, (message, options, theme) => {
|
|
65
81
|
const text = typeof message.content === "string" ? message.content : "";
|
|
66
|
-
|
|
82
|
+
let last;
|
|
83
|
+
return { invalidate() { last = undefined; }, render: (width) => {
|
|
84
|
+
if (last?.width !== width)
|
|
85
|
+
last = { width, lines: card(theme, "muted", "you, in the subagent view", text.split("\n"), width, options.expanded) };
|
|
86
|
+
return last.lines;
|
|
87
|
+
} };
|
|
67
88
|
});
|
|
68
89
|
}
|
package/dist/ui/data.js
CHANGED
|
@@ -16,9 +16,38 @@ function readLive(path) {
|
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
const indexes = new WeakMap();
|
|
20
|
+
/** The last call proposal per key/gen and the last loss and launch per execution, built once per journal snapshot
|
|
21
|
+
* instead of scanning the whole journal for every call on every refresh. */
|
|
22
|
+
function journalIndex(journal) {
|
|
23
|
+
const hit = indexes.get(journal);
|
|
24
|
+
if (hit && hit.length === journal.length)
|
|
25
|
+
return hit;
|
|
26
|
+
const index = { length: journal.length, calls: new Map(), losses: new Map(), execs: new Map() };
|
|
27
|
+
journal.forEach((e, i) => {
|
|
28
|
+
if (e.type === "call")
|
|
29
|
+
index.calls.set(`${e.key}\0${e.gen}`, e);
|
|
30
|
+
else if (e.type === "loss")
|
|
31
|
+
index.losses.set(String(e.exec), i);
|
|
32
|
+
else if (e.type === JT.exec)
|
|
33
|
+
index.execs.set(String(e.exec), i);
|
|
34
|
+
});
|
|
35
|
+
indexes.set(journal, index);
|
|
36
|
+
return index;
|
|
37
|
+
}
|
|
38
|
+
/** A map whose missing entries are produced on first `get`/`has`; iteration and `size` cover only produced entries. */
|
|
39
|
+
export class LazyMap extends Map {
|
|
40
|
+
produce;
|
|
41
|
+
constructor(produce) { super(); this.produce = produce; }
|
|
42
|
+
put(key, value) { super.set(key, value); }
|
|
43
|
+
get(key) { if (!super.has(key))
|
|
44
|
+
this.produce(key); return super.get(key); }
|
|
45
|
+
has(key) { return this.get(key) !== undefined; }
|
|
46
|
+
}
|
|
19
47
|
/** A1, P25: Read the UI's data from durable workflow snapshots and native session tails only. */
|
|
20
48
|
export class UiData {
|
|
21
49
|
workflows = [];
|
|
50
|
+
/** Facts and session branch per call id; a finished call's are read on first `get` (see refresh). */
|
|
22
51
|
facts = new Map();
|
|
23
52
|
sessions = new Map();
|
|
24
53
|
aliases = {};
|
|
@@ -26,36 +55,67 @@ export class UiData {
|
|
|
26
55
|
dock = "auto";
|
|
27
56
|
/** Where the dock sits: below the editor (default; never splits an editor header such as a powerline bar) or above. */
|
|
28
57
|
dockAt = "above";
|
|
58
|
+
/** Per-refresh budget (ms) for reading not yet read finished sessions (history) in the background. */
|
|
59
|
+
warmMs = 15;
|
|
29
60
|
tails = new Map();
|
|
61
|
+
/** Branch and facts per call, reused while its session tail is unchanged: a finished call's session never grows, and
|
|
62
|
+
* re-deriving every historical call on each refresh blocked pi's main thread for over a second. */
|
|
63
|
+
derived = new Map();
|
|
30
64
|
home;
|
|
31
65
|
constructor(home) { this.home = home; }
|
|
32
66
|
refresh() {
|
|
33
|
-
const workflows = allWorkflows(this.home),
|
|
67
|
+
const workflows = allWorkflows(this.home), known = new Set(), deferred = new Map();
|
|
68
|
+
const facts = new LazyMap(key => deferred.get(key)?.()), sessions = new LazyMap(key => deferred.get(key)?.());
|
|
34
69
|
for (const w of workflows) {
|
|
35
|
-
const
|
|
70
|
+
const index = journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
|
|
36
71
|
for (const c of w.calls) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
this.tails.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
known.add(c.callId);
|
|
73
|
+
const load = () => {
|
|
74
|
+
deferred.delete(c.callId);
|
|
75
|
+
let tail = this.tails.get(c.callId);
|
|
76
|
+
if (!tail) {
|
|
77
|
+
tail = new SessionTail();
|
|
78
|
+
this.tails.set(c.callId, tail);
|
|
79
|
+
}
|
|
80
|
+
const source = tail.read(callSession(this.home, w.wid, c.key, c.gen));
|
|
81
|
+
let cached = this.derived.get(c.callId);
|
|
82
|
+
if (!cached || cached.source !== source || cached.length !== source.length) {
|
|
83
|
+
const entries = sessionBranch(source);
|
|
84
|
+
cached = { source, length: source.length, entries, facts: sessionFacts(entries, c.callId) };
|
|
85
|
+
this.derived.set(c.callId, cached);
|
|
86
|
+
}
|
|
87
|
+
const value = { ...cached.facts, live: undefined };
|
|
88
|
+
const proposal = index.calls.get(`${c.key}\0${c.gen}`);
|
|
89
|
+
value.task ||= String(proposal?.spec?.task ?? "");
|
|
90
|
+
const loss = index.losses.get(String(c.exec)) ?? -1;
|
|
91
|
+
const launch = index.execs.get(String(c.exec)) ?? -1;
|
|
92
|
+
if (c.phase !== "sealed" && loss > launch)
|
|
93
|
+
value.activity = "connection dropped, retrying";
|
|
94
|
+
if (c.phase !== "sealed")
|
|
95
|
+
value.live = readLive(join(callDir(this.home, w.wid, c.key, c.gen), LIVE_FILE));
|
|
96
|
+
sessions.put(c.callId, cached.entries);
|
|
97
|
+
facts.put(c.callId, value);
|
|
98
|
+
};
|
|
99
|
+
// A finished call never read before (history at startup) is read when first shown or by the idle warm-up below;
|
|
100
|
+
// reading every historical session up front blocked pi's startup for seconds.
|
|
101
|
+
if (c.phase === "sealed" && !this.tails.has(c.callId))
|
|
102
|
+
deferred.set(c.callId, load);
|
|
103
|
+
else
|
|
104
|
+
load();
|
|
54
105
|
}
|
|
55
106
|
}
|
|
56
107
|
for (const key of this.tails.keys())
|
|
57
|
-
if (!
|
|
108
|
+
if (!known.has(key)) {
|
|
58
109
|
this.tails.delete(key);
|
|
110
|
+
this.derived.delete(key);
|
|
111
|
+
}
|
|
112
|
+
// Warm the history a slice at a time, so opening the list later finds it read without one long stall now.
|
|
113
|
+
const until = performance.now() + this.warmMs;
|
|
114
|
+
for (const load of [...deferred.values()]) {
|
|
115
|
+
if (performance.now() >= until)
|
|
116
|
+
break;
|
|
117
|
+
load();
|
|
118
|
+
}
|
|
59
119
|
this.workflows = workflows;
|
|
60
120
|
this.facts = facts;
|
|
61
121
|
this.sessions = sessions;
|
package/dist/ui/session.js
CHANGED
|
@@ -84,7 +84,22 @@ export function thoughtSummary(text) {
|
|
|
84
84
|
const headings = [...text.matchAll(/\*\*([^*\n]+)\*\*|^#{1,6}\s+(.+)$/gm)];
|
|
85
85
|
if (headings.length)
|
|
86
86
|
return (headings.at(-1)[1] ?? headings.at(-1)[2]).trim();
|
|
87
|
-
return text
|
|
87
|
+
return lastSentence(text);
|
|
88
|
+
}
|
|
89
|
+
const STOPS = new Set([...".!?。!?"]);
|
|
90
|
+
/** The last match of /[^.!?。!?]+[.!?。!?](?=\s|$)/gu without the regex: a long thought with no sentence end made
|
|
91
|
+
* that regex retry from every position (quadratic), which stalled pi's startup on long histories. Such a match is a
|
|
92
|
+
* whole run of non-stop characters followed by one stop that ends the text or precedes whitespace. */
|
|
93
|
+
function lastSentence(text) {
|
|
94
|
+
for (let end = text.length - 1; end > 0; end--) {
|
|
95
|
+
if (!STOPS.has(text[end]) || STOPS.has(text[end - 1]) || (end + 1 < text.length && !/\s/u.test(String.fromCodePoint(text.codePointAt(end + 1)))))
|
|
96
|
+
continue;
|
|
97
|
+
let start = end - 1;
|
|
98
|
+
while (start > 0 && !STOPS.has(text[start - 1]))
|
|
99
|
+
start--;
|
|
100
|
+
return text.slice(start, end + 1).trim();
|
|
101
|
+
}
|
|
102
|
+
return "";
|
|
88
103
|
}
|
|
89
104
|
/** UI §2–3, P31: Derive model, thinking, tool activity and the call's own tool-call count solely from committed session entries. */
|
|
90
105
|
export function sessionFacts(entries, call) {
|
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.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
|
@@ -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")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-durable-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
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",
|