pi-durable-subagents 1.0.4 → 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,28 @@
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
+
3
26
  ## 1.0.4
4
27
 
5
28
  - The TUI no longer stalls pi with a long subagent history. Its 500 ms refresh
package/README.md CHANGED
@@ -72,6 +72,16 @@ subagents({ action: "send", to: "<wid>/<key>", kind: "steer", message: "Don't to
72
72
  subagents({ action: "status" })
73
73
  ```
74
74
 
75
+ `status` without a wid is brief: what runs, what asks (with the address to
76
+ answer) or failed, and one line per finished workflow. `status` with a wid
77
+ shows one workflow with outputs clipped; add `key` for one call's full result,
78
+ or `full: true` for everything. When a run replies `{submitted: {rid}}`
79
+ (its workflow was not created within 10 s), the rid works wherever a wid does.
80
+ With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`, `isolation`,
81
+ `context`, `tools`, `skills` and `once` apply to every step that does not set
82
+ its own; other call fields there, and any of them beside a workflow script,
83
+ are refused rather than ignored.
84
+
75
85
  Every run is asynchronous. The agent is woken once, when the workflow
76
86
  finishes (the notice carries each subagent's result) or when a subagent asks
77
87
  it something. Each verb means one thing, and a refusal says what would work:
@@ -79,7 +89,7 @@ it something. Each verb means one thing, and a refusal says what would work:
79
89
  | Verb | Applies to | Effect |
80
90
  |---|---|---|
81
91
  | `run` | — | Start one subagent, `tasks` in parallel, a `chain`, or a workflow script. An unknown agent name is refused before anything starts, with the list of agents. |
82
- | `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`. |
92
+ | `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`; To one waiting on its question: it interrupts the question, and the subagent usually asks again; `answer` answers it. |
83
93
  | `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). |
84
94
  | `send answer` | an open question | Answers it once. |
85
95
  | `send model` | any subagent | Switches its model at the next request. |
@@ -78,6 +78,14 @@ export function attention(home, sender, seen) {
78
78
  }
79
79
  return items;
80
80
  }
81
+ /** What the main agent reads for an attention item: the text, addressed. A question's own text named neither the asking
82
+ * call nor how to answer it, so answers went out as steers or to the wrong id. */
83
+ export function presentText(item) {
84
+ const call = item.call ? item.call.replace(/@\d+\/([^@/]+)@\d+$/, "/$1") : undefined, where = call ?? item.wid;
85
+ if (item.kind === "question" && call)
86
+ return `Question from ${call}${item.qid ? ` (qid ${item.qid})` : ""}; reply with send kind:"answer" to:"${call}": ${item.text}`;
87
+ return item.text.includes(item.wid) ? item.text : `${where}: ${item.text}`;
88
+ }
81
89
  /** P15: Refresh a question against durable workflow and child receipts at request time. */
82
90
  export function resolved(home, item) {
83
91
  if (readJournalSnapshot(journalPath(home, item.wid)).some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
@@ -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) {
@@ -300,14 +300,20 @@ function origins(home) {
300
300
  ids.set(wid, undefined);
301
301
  return ids;
302
302
  }
303
- /** P25, T10: Compact status of all workflows: own session first, then newest first; finished ones beyond the first `keep` collapse into a count. */
304
- export function statusView(home, options = {}) {
305
- 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);
306
306
  const { since, held } = heldWorkflows(home);
307
307
  const all = [...origins(home)].sort(([a], [b]) => a < b ? 1 : a > b ? -1 : 0).map(([wid, origin]) => {
308
308
  const wf = workflowSnapshot(home, wid), withOrigin = wf.origin === undefined && origin !== undefined ? { ...wf, origin } : wf;
309
309
  return withOrigin.status === "running" && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
310
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);
311
317
  let finished = 0;
312
318
  const shown = all.filter(w => !["done", "failed", "stopped"].includes(w.status) || ++finished <= keep);
313
319
  const hidden = all.length - shown.length;
@@ -315,6 +321,88 @@ export function statusView(home, options = {}) {
315
321
  return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
316
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)` } : {}) };
317
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
+ }
318
406
  /** P25, T10: One workflow in full detail (results, outputs, script log path) but without raw journal entries. */
319
407
  export function statusDetail(home, wid) {
320
408
  const origin = origins(home);
package/dist/ui/cards.js CHANGED
@@ -61,7 +61,8 @@ export function registerCards(pi, home) {
61
61
  let last;
62
62
  const draw = (width) => items.flatMap(item => {
63
63
  const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
64
- const heading = `${h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? "— answered" : h.title}`;
64
+ const title = item.kind === "stall" && item.id.startsWith("noprogress:") ? "no progress" : h.title;
65
+ const heading = `${h.icon} ${item.kind === "finished" && !item.call ? "Workflow" : `Subagent ${keyOf(item)}`} ${closed ? "— answered" : title}`;
65
66
  // v12 §3: a finished digest is first line + dim per-agent lines, clipped; old single-line items read exactly as before.
66
67
  const inner = Math.max(1, Math.max(3, Math.floor(width)) - 4);
67
68
  const body = item.kind === "finished" && !closed
package/dist/ui/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.4",
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",