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 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 = ledger.find(e => e.type === JT.created && e.wid === wid) ?? entries.find(e => e.type === JT.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
- /** P15, V7: Rebuild session-wide receipts, including entries hidden by compaction. */
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
- return ctx.sessionManager.getEntries().flatMap(entry => entry.type === "custom_message" && entry.customType === CT.attention
42
- ? entry.details?.items ?? [] : []);
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 entry of workflow.entries) {
51
- if (entry.type !== JT.attention)
52
- continue;
53
- const item = entry.item;
54
- if (workflow.entries.some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
55
- continue;
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))
@@ -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
@@ -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.size > 0;
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(i => i.text).join("\n"), display: true, details: { items } };
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.text})` : item.text).join("\n") };
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
- if (args.action === "status")
223
- return typeof args.wid === "string" && args.wid ? statusDetail(home, args.wid) : statusView(home, { origin: sender });
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, model, cwd, timeoutMs, usageBudget, maxCalls, inputs. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
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 [wid] gives a digest. revise wid + workflow/source/args starts a revision.",
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.text})`), `AC4 stale reminder ${item.id}@${item.rev}`);
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 };
@@ -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("gate.timeoutMs must be a positive number");
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("agent must be a non-empty string");
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("timeoutMs must be a positive number");
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('isolation must be "none" or "worktree"');
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('context must be "fresh" or "fork"');
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("once must be a boolean");
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: "No execution activity", wid: t.wid, call: t.callId } });
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
- return { report, budget, text, dangling: [...tools].map(([id, name]) => `${name} (${id})`), usage };
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 wf = snapshotFromEntries(wid, readJournalSnapshot(journalPath(home, wid)));
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
- /** P25, T10: Compact status of all workflows: own session first, then newest first; finished ones beyond the first `keep` collapse into a count. */
298
- export function statusView(home, options = {}) {
299
- const keep = options.keep ?? 10, own = (w) => Number(!!options.origin && w.origin === options.origin);
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
- return { invalidate() { }, render: (width) => items.flatMap(item => {
54
- const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
55
- const heading = `${h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? "— answered" : h.title}`;
56
- // v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
57
- const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
58
- const body = item.kind === "finished" && !closed
59
- ? digestLines(item.text, line => theme.fg("dim", line), inner)
60
- : [closed ? theme.fg("dim", item.text) : item.text];
61
- return card(theme, closed ? "muted" : h.tone, heading, body, width, options.expanded);
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
- return { invalidate() { }, render: (width) => card(theme, "muted", "you, in the subagent view", text.split("\n"), width, options.expanded) };
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), facts = new Map(), sessions = new Map();
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 journal = readJournalSnapshot(journalPath(this.home, w.wid));
70
+ const index = journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
36
71
  for (const c of w.calls) {
37
- let tail = this.tails.get(c.callId);
38
- if (!tail) {
39
- tail = new SessionTail();
40
- this.tails.set(c.callId, tail);
41
- }
42
- const entries = sessionBranch(tail.read(callSession(this.home, w.wid, c.key, c.gen)));
43
- const value = sessionFacts(entries, c.callId);
44
- const proposal = journal.findLast(e => e.type === "call" && e.key === c.key && e.gen === c.gen);
45
- value.task ||= String(proposal?.spec?.task ?? "");
46
- const loss = journal.findLastIndex(e => e.type === "loss" && e.exec === c.exec);
47
- const launch = journal.findLastIndex(e => e.type === JT.exec && e.exec === c.exec);
48
- if (c.phase !== "sealed" && loss > launch)
49
- value.activity = "connection dropped, retrying";
50
- if (c.phase !== "sealed")
51
- value.live = readLive(join(callDir(this.home, w.wid, c.key, c.gen), LIVE_FILE));
52
- sessions.set(c.callId, entries);
53
- facts.set(c.callId, value);
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 (!sessions.has(key))
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;
@@ -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.match(/[^.!?。!?]+[.!?。!?](?=\s|$)/gu)?.at(-1)?.trim() ?? "";
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 (r === "nothing-to-resume")
76
- return "nothing is paused, so there is nothing to resume";
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",
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",