pi-durable-subagents 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.6
4
+
5
+ - A follow-up on a finished workflow shows as running work: in the dock, the
6
+ summary line, the list (no longer dimmed or hidden) and `status`. Quitting pi
7
+ pauses it, and `resume` continues it; before, a resume refused it as finished.
8
+ - ↓ opens the subagent list only from pi's input editor. With `/model`'s
9
+ selector, a dialog or another overlay in focus, ↓ moves in that list again.
10
+
11
+ ## 1.0.5
12
+
13
+ - `status` without a wid is brief: what runs, what asks (with the address to
14
+ answer) and what failed, with finished workflows one line each; it used to
15
+ return every call's usage and last line (tens of thousands of tokens on a
16
+ busy home). With a wid, outputs are clipped; `key` gives one call's full
17
+ result and `full: true` the previous complete detail.
18
+ - A run's rid (from `{submitted: {rid}}`) works wherever a wid is expected.
19
+ - With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`,
20
+ `isolation`, `context`, `tools`, `skills` and `once` are defaults for every
21
+ step; other fields there, or any call field beside a workflow script, are
22
+ refused instead of silently ignored. Invalid fields report the value
23
+ received.
24
+ - A call that keeps running without producing output tokens or tool results
25
+ (for example, retrying against an exhausted provider) raises a
26
+ "no progress" alert after 10 minutes (`k.progressMs`), with the last
27
+ provider error. A call that ends on an explicit quota or billing error fails
28
+ at once with `Provider error: …` instead of being relaunched; other lost
29
+ executions report their last error.
30
+ - Attention reaches the main agent with the call's address; a question says
31
+ how to answer it.
32
+ - `resume` without a wid names workflows that other sessions hold paused.
33
+
3
34
  ## 1.0.4
4
35
 
5
36
  - The TUI no longer stalls pi with a long subagent history. Its 500 ms refresh
package/README.md CHANGED
@@ -72,6 +72,16 @@ subagents({ action: "send", to: "<wid>/<key>", kind: "steer", message: "Don't to
72
72
  subagents({ action: "status" })
73
73
  ```
74
74
 
75
+ `status` without a wid is brief: what runs, what asks (with the address to
76
+ answer) or failed, and one line per finished workflow. `status` with a wid
77
+ shows one workflow with outputs clipped; add `key` for one call's full result,
78
+ or `full: true` for everything. When a run replies `{submitted: {rid}}`
79
+ (its workflow was not created within 10 s), the rid works wherever a wid does.
80
+ With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`, `isolation`,
81
+ `context`, `tools`, `skills` and `once` apply to every step that does not set
82
+ its own; other call fields there, and any of them beside a workflow script,
83
+ are refused rather than ignored.
84
+
75
85
  Every run is asynchronous. The agent is woken once, when the workflow
76
86
  finishes (the notice carries each subagent's result) or when a subagent asks
77
87
  it something. Each verb means one thing, and a refusal says what would work:
@@ -79,7 +89,7 @@ it something. Each verb means one thing, and a refusal says what would work:
79
89
  | Verb | Applies to | Effect |
80
90
  |---|---|---|
81
91
  | `run` | — | Start one subagent, `tasks` in parallel, a `chain`, or a workflow script. An unknown agent name is refused before anything starts, with the list of agents. |
82
- | `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`. |
92
+ | `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`; To one waiting on its question: it interrupts the question, and the subagent usually asks again; `answer` answers it. |
83
93
  | `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). |
84
94
  | `send answer` | an open question | Answers it once. |
85
95
  | `send model` | any subagent | Switches its model at the next request. |
@@ -78,6 +78,14 @@ export function attention(home, sender, seen) {
78
78
  }
79
79
  return items;
80
80
  }
81
+ /** What the main agent reads for an attention item: the text, addressed. A question's own text named neither the asking
82
+ * call nor how to answer it, so answers went out as steers or to the wrong id. */
83
+ export function presentText(item) {
84
+ const call = item.call ? item.call.replace(/@\d+\/([^@/]+)@\d+$/, "/$1") : undefined, where = call ?? item.wid;
85
+ if (item.kind === "question" && call)
86
+ return `Question from ${call}${item.qid ? ` (qid ${item.qid})` : ""}; reply with send kind:"answer" to:"${call}": ${item.text}`;
87
+ return item.text.includes(item.wid) ? item.text : `${where}: ${item.text}`;
88
+ }
81
89
  /** P15: Refresh a question against durable workflow and child receipts at request time. */
82
90
  export function resolved(home, item) {
83
91
  if (readJournalSnapshot(journalPath(home, item.wid)).some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
@@ -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 { isLive, pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
16
16
  import { parameters, request } from "./main/tool.js";
17
17
  import { discoverAgents } from "../compat/agents.js";
18
18
  let noteSink;
@@ -61,7 +61,8 @@ export function registerMain(pi, ui) {
61
61
  if (entry.type === JT.created)
62
62
  await outbox?.markResolved(String(entry.rid));
63
63
  }
64
- function pendingOutbox() {
64
+ function pendingOutbox() { return pendingRids().size > 0; }
65
+ function pendingRids() {
65
66
  const pending = new Set();
66
67
  for (const entry of readJournalSnapshot(join(outboxRoot(home), "outbox", `${sender}.jsonl`))) {
67
68
  if (entry.type === "sent")
@@ -69,7 +70,7 @@ export function registerMain(pi, ui) {
69
70
  else if (entry.type === "resolved")
70
71
  pending.delete(String(entry.rid));
71
72
  }
72
- return pending.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())
@@ -164,7 +165,7 @@ export function registerMain(pi, ui) {
164
165
  pi.on("session_start", async (_event, context) => {
165
166
  await start(context);
166
167
  // Quit pause: say once, at the session's start, which of its workflows wait for a resume.
167
- const paused = statusView(home, { origin: sender }).workflows.filter(w => w.origin === sender && w.status === "running" && w.paused);
168
+ const paused = statusView(home, { origin: sender }).workflows.filter(w => w.origin === sender && isLive(w) && w.paused);
168
169
  if (paused.length)
169
170
  pi.sendMessage({ customType: CT.note, display: true, content: `${paused.length} subagent workflow${paused.length > 1 ? "s were" : " was"} paused when pi quit (${paused.map(w => w.name ?? w.wid).join(", ")}). Ask to resume, or open /subagents (or ↓) and press r.` });
170
171
  });
@@ -175,7 +176,7 @@ export function registerMain(pi, ui) {
175
176
  pi.on("session_shutdown", async (event) => {
176
177
  if (event.reason === "quit" && quitPolicy(home) === "pause" && outbox && !stopped) {
177
178
  try {
178
- const running = statusView(home, { origin: sender }).workflows.some(w => w.origin === sender && w.status === "running" && !w.paused);
179
+ const running = statusView(home, { origin: sender }).workflows.some(w => w.origin === sender && isLive(w) && !w.paused);
179
180
  if (running)
180
181
  await serial(async () => { await outbox?.send("orch", "drain", { fence: true, origin: sender }); });
181
182
  }
@@ -193,7 +194,7 @@ export function registerMain(pi, ui) {
193
194
  const items = msg.details?.items;
194
195
  if (!items)
195
196
  return msg;
196
- return { ...msg, content: items.map(item => resolved(home, item) ? `(resolved: ${item.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 };
package/dist/cli/main.js CHANGED
@@ -64,7 +64,7 @@ export function renderStatus(wf) {
64
64
  }
65
65
  /** P25, T10: Render the compact status projection shared with the `subagents` tool. */
66
66
  export function renderView(view) {
67
- const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
67
+ const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.followUps ? " (follow-up running)" : ""}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
68
68
  ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
69
69
  ...w.attention.map(a => ` ${a.kind}: ${JSON.stringify(a.text.split("\n")[0])}`)].join("\n"));
70
70
  if (view.paused)
@@ -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;
@@ -97,6 +97,11 @@ export class Engine {
97
97
  held(wid) {
98
98
  return holdOf(this.ledgers.orch.entries(), wid, this.store.workflows.get(wid)?.origin) !== undefined;
99
99
  }
100
+ /** Follow-ups (generations) of the current revision that have not ended: on a finished workflow they are its only live work. */
101
+ openFollowUps(wf) {
102
+ const log = revisionEntries(wf), ended = new Set(log.filter(e => e.type === JT.sealed || e.type === 'retired').map(e => String(e.call)));
103
+ return log.some(e => e.type === 'generation' && !ended.has(`${wf.wid}@${wf.revision}/${e.key}@${e.gen}`));
104
+ }
100
105
  watcher;
101
106
  poll;
102
107
  constructor(ledgers, executor, options = {}) {
@@ -370,7 +375,8 @@ export class Engine {
370
375
  const inScope = (wf) => wid ? wf.wid === wid : origin ? wf.origin === origin : true;
371
376
  const took = (wf) => wf.journal.entries().some(e => e.type === 'resumed' && e.rid === req.rid);
372
377
  const replayed = this.ledgers.orch.entries().some(e => e.type === 'undrain' && e.rid === req.rid);
373
- const holding = [...this.store.workflows.values()].filter(wf => inScope(wf) && this.held(wf.wid) && !this.terminal(wf));
378
+ // Held follow-ups of a finished workflow are released too; they run again through dispatchGeneration below.
379
+ const holding = [...this.store.workflows.values()].filter(wf => inScope(wf) && this.held(wf.wid) && (!this.terminal(wf) || this.openFollowUps(wf)));
374
380
  const releasing = replayed || holding.length > 0;
375
381
  const final = (wf) => { const done = this.terminal(wf); return done && done.status !== 'parked' ? String(done.status) : undefined; };
376
382
  const parked = (wf) => this.terminal(wf)?.status === 'parked';
@@ -22,7 +22,7 @@ import { buildCallResult } from "../../compat/result.js";
22
22
  import createEffects from "./effects/index.js";
23
23
  import { continueSession } from "./generation.js";
24
24
  import { hibernation, openQuestion } from "./hibernate.js";
25
- import { evidence, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
25
+ import { evidence, fatalProviderError, forgetSession, readSessionState, receiptId, sessionModel } from "./session.js";
26
26
  import { activeTotal } from "./time.js";
27
27
  import { observeExecution } from "./observe.js";
28
28
  import { availableMemory } from "./memory.js";
@@ -572,6 +572,8 @@ export default function createExecutor(ledgers, options = {}) {
572
572
  return finish(journal, t.callId, exec, { ...makeResult("ok", ev.text), usage: ev.usage });
573
573
  if (t.spec.once && dangling.length)
574
574
  return finish(journal, t.callId, exec, makeResult("unknown", "", `Unknown tool outcomes: ${dangling.join(", ")}`));
575
+ if (has(journal, "settled", exec) && !ev.text && ev.error && fatalProviderError(ev.error))
576
+ return finish(journal, t.callId, exec, makeResult("failed", "", `Provider error: ${ev.error}`));
575
577
  await serial(async () => {
576
578
  if (!has(journal, "loss", exec))
577
579
  await journal.append("loss", { exec });
@@ -579,7 +581,7 @@ export default function createExecutor(ledgers, options = {}) {
579
581
  });
580
582
  const losses = journal.entries().filter(e => e.type === "loss" && String(e.exec).startsWith(`${t.callId}#`)).length;
581
583
  if (losses >= (config.k?.lossBound ?? 5))
582
- return finish(journal, t.callId, exec, makeResult("failed", "", `lost ×${losses}`));
584
+ return finish(journal, t.callId, exec, makeResult("failed", "", `lost ×${losses}${ev.error ? `; last error: ${ev.error.slice(0, 300)}` : ""}`));
583
585
  await release(exec);
584
586
  }
585
587
  }
@@ -15,6 +15,8 @@ import { observation, reached, sessionUsage, totalUsage } from "./usage.js";
15
15
  export async function observeExecution(d) {
16
16
  const { home, config, ticket: t, exec, child, serial } = d;
17
17
  const session = callSession(home, t.wid, t.key, t.gen), clock = new ActiveTime(), started = clock.last;
18
+ let progress = started, providerError;
19
+ const tools = new Set();
18
20
  const prior = activeTotal(t.journal.entries(), t.callId);
19
21
  let size = (await fileStat(session).catch(() => ({ size: 0 }))).size, checkpoint = performance.now();
20
22
  let signal;
@@ -47,7 +49,21 @@ export async function observeExecution(d) {
47
49
  if (open && fresh)
48
50
  await t.journal.append(JT.attentionResolved, { id, rev: last.rev, resolution: "activity" });
49
51
  else if (!open && !clock.asking && performance.now() - clock.last >= (config.k?.stallMs ?? 600000))
50
- await t.journal.append(JT.attention, { exec, horizon: clock.last, item: { id, rev: (last?.rev ?? 0) + 1, kind: "stall", text: "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) {
@@ -6,6 +6,14 @@ import { compileFanout } from "../compat/fanout.js";
6
6
  import { readJournalSnapshot } from "../kernel/journal.js";
7
7
  import { journalPath, orchLedger, pinnedDir, workflowDir } from "../paths.js";
8
8
  import { JT } from "../types.js";
9
+ /** A workflow has live work: it runs, or follow-ups opened on it after it finished have not ended yet. */
10
+ export function isLive(wf) {
11
+ return wf.status === "running" || (wf.followUps ?? 0) > 0;
12
+ }
13
+ /** The calls of a workflow that are live work: every unfinished call of a running workflow, else open follow-ups. */
14
+ export function liveCalls(wf) {
15
+ return wf.calls.filter(c => c.phase !== "sealed" && (wf.status === "running" || c.afterEnd === true));
16
+ }
9
17
  /** v12 §4: done/total of one workflow: `done` counts keys whose latest generation is sealed; `total` is the planned
10
18
  * count when known, else keys proposed so far (`plus` marks the `n+` script case while the workflow runs). */
11
19
  export function progressOf(wf) {
@@ -69,6 +77,7 @@ export function snapshotFromEntries(wid, entries) {
69
77
  const calls = new Map();
70
78
  const byExec = new Map();
71
79
  const resolved = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}@${e.rev}`));
80
+ const generations = new Set(), retired = new Set();
72
81
  const attention = [];
73
82
  // P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
74
83
  const live = new Map(), sealedUsage = new Map(), seen = new Set();
@@ -81,6 +90,8 @@ export function snapshotFromEntries(wid, entries) {
81
90
  continue;
82
91
  const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
83
92
  const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
93
+ if (e.type === "generation")
94
+ generations.add(callId);
84
95
  const result = e.type === "refused" ? refusedResult(key, e.reason) :
85
96
  e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
86
97
  const wanted = e.spec?.model;
@@ -90,6 +101,9 @@ export function snapshotFromEntries(wid, entries) {
90
101
  phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
91
102
  ...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
92
103
  }
104
+ else if (e.type === "retired") {
105
+ retired.add(String(e.call));
106
+ }
93
107
  else if (e.type === JT.exec) {
94
108
  const call = calls.get(String(e.call));
95
109
  if (!call)
@@ -197,6 +211,10 @@ export function snapshotFromEntries(wid, entries) {
197
211
  c.switching = switching;
198
212
  }
199
213
  }
214
+ const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
215
+ for (const c of after)
216
+ c.afterEnd = true;
217
+ const followUps = after.length;
200
218
  const usage = zero();
201
219
  for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
202
220
  const u = usageOf(id);
@@ -210,6 +228,7 @@ export function snapshotFromEntries(wid, entries) {
210
228
  status: done ? done.status : "running",
211
229
  ...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
212
230
  ...(done ? { endedAt: done.ts } : {}),
231
+ ...(followUps ? { followUps } : {}),
213
232
  calls: list, counts, attention, usage,
214
233
  };
215
234
  }
@@ -262,7 +281,7 @@ export function heldWorkflows(home) {
262
281
  /** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
263
282
  export function allWorkflows(home) {
264
283
  const { held } = heldWorkflows(home);
265
- return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return wf.status === "running" && held(wid) ? { ...wf, paused: true } : wf; });
284
+ return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return isLive(wf) && held(wid) ? { ...wf, paused: true } : wf; });
266
285
  }
267
286
  /** P10/P11: Per-workflow script console log written by the orchestrator (bounded, human-readable). */
268
287
  export function scriptLogPath(home, wid) { return join(workflowDir(home, wid), "script.log"); }
@@ -286,7 +305,7 @@ export function compactWorkflow(wf) {
286
305
  ...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}) };
287
306
  }),
288
307
  attention: wf.attention.map(a => ({ id: a.id, rev: a.rev, kind: a.kind, text: clip(a.text, 300), ...(a.call ? { call: a.call } : {}), ...(a.qid ? { qid: a.qid } : {}) })),
289
- ...(wf.paused ? { paused: true } : {}),
308
+ ...(wf.paused ? { paused: true } : {}), ...(wf.followUps ? { followUps: wf.followUps } : {}),
290
309
  };
291
310
  }
292
311
  function origins(home) {
@@ -300,21 +319,111 @@ function origins(home) {
300
319
  ids.set(wid, undefined);
301
320
  return ids;
302
321
  }
303
- /** 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);
322
+ /** Every workflow with its origin and hold, own session first, then newest first. */
323
+ function snapshots(home, origin) {
324
+ const own = (w) => Number(!!origin && w.origin === origin);
306
325
  const { since, held } = heldWorkflows(home);
307
326
  const all = [...origins(home)].sort(([a], [b]) => a < b ? 1 : a > b ? -1 : 0).map(([wid, origin]) => {
308
327
  const wf = workflowSnapshot(home, wid), withOrigin = wf.origin === undefined && origin !== undefined ? { ...wf, origin } : wf;
309
- return withOrigin.status === "running" && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
328
+ return isLive(withOrigin) && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
310
329
  }).sort((a, b) => own(b) - own(a));
330
+ return { all, ...(since !== undefined ? { since } : {}) };
331
+ }
332
+ /** P25, T10: Compact status of all workflows: own session first, then newest first; finished ones beyond the first `keep` collapse into a count. */
333
+ export function statusView(home, options = {}) {
334
+ const keep = options.keep ?? 10;
335
+ const { all, since } = snapshots(home, options.origin);
311
336
  let finished = 0;
312
- const shown = all.filter(w => !["done", "failed", "stopped"].includes(w.status) || ++finished <= keep);
337
+ const shown = all.filter(w => !settled(w) || ++finished <= keep);
313
338
  const hidden = all.length - shown.length;
314
339
  const paused = all.filter(w => w.paused).length;
315
340
  return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
316
341
  ...(paused ? { paused: `${paused} workflow${paused > 1 ? "s" : ""} paused (stop-all, drain or a quit pi) since ${new Date(since).toISOString()}; resume continues them (new runs are not affected)` } : {}) };
317
342
  }
343
+ const FINAL = ["done", "failed", "stopped"];
344
+ /** Finished with nothing left running (a follow-up on a finished workflow is live work). */
345
+ const settled = (w) => FINAL.includes(w.status) && !w.followUps;
346
+ const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms < 3_600_000 ? `${Math.round(ms / 60_000)}m` : `${(ms / 3_600_000).toFixed(1)}h`;
347
+ const tokens = (u) => { const n = u ? u.input + u.output : 0; return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n); };
348
+ /** The latest generation of every key, in first-call order. */
349
+ const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()];
350
+ /** Tool status without a wid: what runs, what waits for an answer and what failed, with finished workflows one line each.
351
+ * The full view (every call's last line and usage) ran to tens of thousands of tokens on a busy home. */
352
+ export function statusBrief(home, options = {}) {
353
+ const keep = options.keep ?? 5, now = options.now ?? Date.now(), origin = options.origin;
354
+ const { all } = snapshots(home, origin);
355
+ const mine = (w) => !origin || w.origin === origin;
356
+ const line = (w) => {
357
+ const p = progressOf(w), notOk = latestCalls(w).filter(c => c.result && !c.result.ok).length;
358
+ return [w.wid, w.name, `${w.paused ? "paused" : w.followUps ? `${w.status}, follow-up running` : w.status}`, `${p.done}/${p.total}${p.plus ? "+" : ""} done`, notOk ? `${notOk} not ok` : "",
359
+ w.endedAt !== undefined ? `ended ${age(now - w.endedAt)} ago` : w.startedAt !== undefined ? `started ${age(now - w.startedAt)} ago` : ""].filter(Boolean).join(" · ");
360
+ };
361
+ const brief = (w) => {
362
+ const p = progressOf(w), open = w.attention.filter(a => a.kind !== "finished");
363
+ const calls = latestCalls(w).filter(c => c.phase !== "sealed" || (c.result && !c.result.ok)).map((c) => {
364
+ const live = c.phase !== "sealed", quiet = c.lastActivity !== undefined ? now - c.lastActivity : undefined;
365
+ return { key: c.key, agent: c.agent, phase: c.phase, ...(c.model ? { model: c.model } : {}),
366
+ ...(live && c.startedAt !== undefined ? { for: age(now - c.startedAt) } : {}),
367
+ ...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
368
+ ...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
369
+ ...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}) };
370
+ });
371
+ const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}), question: clip(a.text, 300) }));
372
+ const alerts = open.filter(a => a.kind !== "question").map(a => `${a.kind}${a.call ? ` ${w.wid}/${callKey(a.call)}` : ""}: ${clip(a.text, 200)}`);
373
+ return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}), ...(w.followUps ? { followUps: w.followUps } : {}),
374
+ progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
375
+ ...(asking.length ? { asking } : {}), ...(alerts.length ? { alerts } : {}) };
376
+ };
377
+ const active = all.filter(w => !settled(w));
378
+ const finished = all.filter(w => settled(w) && mine(w));
379
+ const others = active.filter(w => !mine(w));
380
+ const heldMine = active.filter(w => mine(w) && w.paused).length, heldOthers = others.filter(w => w.paused);
381
+ const paused = [heldMine ? `${heldMine} workflow${heldMine > 1 ? "s" : ""} of this session ${heldMine > 1 ? "are" : "is"} paused (stop-all, drain or a quit pi); resume continues ${heldMine > 1 ? "them" : "it"}` : "",
382
+ heldOthers.length ? `${heldOthers.length} workflow${heldOthers.length > 1 ? "s" : ""} of other sessions paused (${heldOthers.slice(0, 8).map(w => w.wid).join(", ")}); resume wid=<wid> continues one` : ""].filter(Boolean).join("; ");
383
+ return {
384
+ active: active.filter(mine).map(brief), ...(others.length ? { otherSessions: others.slice(0, 10).map(line) } : {}),
385
+ finished: finished.slice(0, keep).map(line), ...(finished.length > keep ? { olderFinished: finished.length - keep } : {}),
386
+ ...(paused ? { paused } : {}),
387
+ hint: "status wid=<wid> shows one workflow (outputs clipped); add key=<key> for one call's full result, or full:true for everything",
388
+ };
389
+ }
390
+ /** Running workflows of other sessions that a pause holds, as "wid" or "wid (name)": a session's own resume skips them. */
391
+ export function pausedElsewhere(home, origin) {
392
+ return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !settled(w)).map(w => w.name ? `${w.wid} (${w.name})` : w.wid);
393
+ }
394
+ /** "<rid>" or "<rid>/<rest>" of a run request that created a workflow → the same address with its wid; else unchanged.
395
+ * A run replies {submitted:{rid}} when its workflow is not created within 10 s, so the rid is all the caller has. */
396
+ export function widOfRid(ledger, value) {
397
+ const cut = value.indexOf("/"), head = cut < 0 ? value : value.slice(0, cut);
398
+ const created = head ? ledger.find(e => e.type === JT.created && e.rid === head) : undefined;
399
+ return created ? `${String(created.wid)}${value.slice(head.length)}` : value;
400
+ }
401
+ const OUTPUT = 600;
402
+ /** Tool status with a wid: one workflow with each call's output clipped (the full detail repeated every output twice and
403
+ * reached ~100K characters); `key` gives one call in full. */
404
+ export function statusCompactDetail(home, wid) {
405
+ const detail = statusDetail(home, wid), compact = compactWorkflow(detail);
406
+ const byId = new Map(detail.calls.map(c => [c.callId, c]));
407
+ const calls = compact.calls.map(({ lastLine: _l, ...c }) => {
408
+ const full = byId.get(c.callId), output = full.result?.output?.trim();
409
+ return { ...c, agent: full.agent, ...(output ? { output: output.length > OUTPUT ? `${output.slice(0, OUTPUT)}… [${output.length - OUTPUT} more chars: status wid key=${c.key}]` : output } : {}) };
410
+ });
411
+ let result;
412
+ if (detail.result !== undefined) {
413
+ const json = JSON.stringify(detail.result) ?? "";
414
+ result = json.length <= 2000 ? detail.result : `${json.slice(0, 2000)}… [${json.length - 2000} more chars: status wid full:true]`;
415
+ }
416
+ return { ...compact, ...(detail.cwd ? { cwd: detail.cwd } : {}), ...(detail.scriptLog ? { scriptLog: detail.scriptLog } : {}),
417
+ ...(result !== undefined ? { result } : {}), calls, hint: "key=<key> gives one call's full result; full:true gives everything" };
418
+ }
419
+ /** Tool status with a wid and key: that call's latest generation in full (result, usage, sends). */
420
+ export function statusCallDetail(home, wid, key) {
421
+ const detail = statusDetail(home, wid);
422
+ const call = detail.calls.findLast(c => c.key === key || c.callId === key || `${c.key}@${c.gen}` === key);
423
+ if (!call)
424
+ throw new Error(`No call "${key}" in ${wid}; calls: ${[...new Set(detail.calls.map(c => c.key))].join(", ")}`);
425
+ return { wid, ...call };
426
+ }
318
427
  /** P25, T10: One workflow in full detail (results, outputs, script log path) but without raw journal entries. */
319
428
  export function statusDetail(home, wid) {
320
429
  const origin = origins(home);
package/dist/ui/cards.js CHANGED
@@ -61,7 +61,8 @@ export function registerCards(pi, home) {
61
61
  let last;
62
62
  const draw = (width) => items.flatMap(item => {
63
63
  const h = HEAD[item.kind] ?? HEAD.unknown, closed = item.kind === "question" && isResolved(item);
64
- const 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/index.js CHANGED
@@ -38,7 +38,19 @@ export function registerUi(pi, deps) {
38
38
  const state = { folded: new Set(), done: new Map(), viewed: new Set(), finished: false };
39
39
  let screen, opening = false, stopped = false, closeScreen;
40
40
  let unsubscribe, timer;
41
- let dockRows = () => [], dockAt, dockTui, lastDock = "";
41
+ let dockRows = () => [], dockAt = null, dockTui, lastDock = "";
42
+ // ↓ opens the list only from pi's own input editor. Another surface — /model's selector, a dialog, another
43
+ // extension's overlay — has the focus instead, and its ↓ belongs to it. pi's editor (and any editor built on
44
+ // CustomEditor) carries the app action map; the TUI comes from the dock widget, so without a dock the check
45
+ // falls back to the editor text alone.
46
+ let keysTui;
47
+ const editorFocused = () => {
48
+ const focus = keysTui?.getFocusedComponent;
49
+ if (typeof focus !== "function")
50
+ return true;
51
+ const focused = focus.call(keysTui);
52
+ return !!focused && typeof focused === "object" && focused.actionHandlers instanceof Map;
53
+ };
42
54
  const right = (text, width) => { const t = truncateToWidth(text, width); return " ".repeat(Math.max(0, width - visibleWidth(t))) + t; };
43
55
  const stop = () => {
44
56
  stopped = true;
@@ -75,8 +87,11 @@ export function registerUi(pi, deps) {
75
87
  if (at !== dockAt) {
76
88
  dockAt = at;
77
89
  dockTui = undefined;
78
- ctx.ui.setWidget("durable-subagents", at ? (tui, theme) => {
90
+ // With the dock off, an empty widget below the editor still lends the TUI to the ↓ focus check; pi adds no
91
+ // spacer for it, so it takes no line.
92
+ ctx.ui.setWidget("durable-subagents", !at ? tui => { keysTui = tui; return { invalidate() { }, render: () => [] }; } : (tui, theme) => {
79
93
  dockTui = tui;
94
+ keysTui = tui;
80
95
  return {
81
96
  invalidate() { },
82
97
  render(width) {
@@ -88,7 +103,7 @@ export function registerUi(pi, deps) {
88
103
  return rows.map((row, i) => ` ${i === rows.length - 1 ? theme.fg("dim", right(row, inner)) : row.startsWith("? ") ? theme.fg("warning", row) : theme.fg("muted", row)}`);
89
104
  },
90
105
  };
91
- } : undefined, { placement: at === "above" ? "aboveEditor" : "belowEditor" });
106
+ }, { placement: at === "above" ? "aboveEditor" : "belowEditor" });
92
107
  }
93
108
  const shown = dockRows(200).join("\n");
94
109
  if (shown !== lastDock) {
@@ -118,7 +133,7 @@ export function registerUi(pi, deps) {
118
133
  };
119
134
  openList = open;
120
135
  unsubscribe = ctx.ui.onTerminalInput(key => {
121
- if (stopped || opening || !matchesKey(key, "down") || ctx.ui.getEditorText() !== "" || !data.workflows.length)
136
+ if (stopped || opening || !matchesKey(key, "down") || ctx.ui.getEditorText() !== "" || !editorFocused() || !data.workflows.length)
122
137
  return;
123
138
  open();
124
139
  return { consume: true };
package/dist/ui/tool.js CHANGED
@@ -30,6 +30,10 @@ export function callLine(args) {
30
30
  /** UI §5: The collapsed result: started / applied / rejected, or one line per workflow for status. */
31
31
  export function resultLines(details) {
32
32
  const d = (details ?? {});
33
+ if (typeof d.wid === "string" && typeof d.key === "string" && typeof d.phase === "string") {
34
+ const r = d.result;
35
+ return [`${d.key} · ${r?.status ?? d.phase}${typeof d.model === "string" ? ` · ${d.model}` : ""}`];
36
+ }
33
37
  if (typeof d.wid === "string" && !Array.isArray(d.calls))
34
38
  return [`started workflow ${d.wid}`, ...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : [])];
35
39
  if (d.applied === true)
@@ -43,6 +47,15 @@ export function resultLines(details) {
43
47
  const asks = (w.attention ?? []).filter(a => a.kind === "question").length;
44
48
  return `${w.name ?? short(w.wid)} · ${w.status} · ${done}/${w.calls.length} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}`;
45
49
  };
50
+ if (Array.isArray(d.active)) {
51
+ const rows = d.active.map(w => {
52
+ const failed = w.calls.filter(c => c.status && c.status !== "ok").length, asks = w.asking?.length ?? 0, alerts = w.alerts?.length ?? 0;
53
+ return `${w.name ?? short(w.wid)} · ${w.paused ? "paused" : w.followUps ? `${w.status}, follow-up running` : w.status} · ${w.progress} done${failed ? ` · ${failed} not ok` : ""}${asks ? ` · ${asks} asking` : ""}${alerts ? ` · ${alerts} alert${alerts > 1 ? "s" : ""}` : ""}`;
54
+ });
55
+ const finished = Array.isArray(d.finished) ? d.finished.length + Number(d.olderFinished ?? 0) : 0;
56
+ return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["nothing running"]),
57
+ ...(rows.length > 6 ? [`… ${rows.length - 6} more`] : []), ...(finished ? [`${finished} finished`] : [])];
58
+ }
46
59
  if (Array.isArray(d.workflows)) {
47
60
  const rows = d.workflows.map(row);
48
61
  return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["no workflows"]), ...(rows.length > 6 ? [`… ${rows.length - 6} more`] : [])];
package/dist/ui/view.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { progressOf } from "../orchestrator/snapshot.js";
2
+ import { isLive, liveCalls, progressOf } from "../orchestrator/snapshot.js";
3
3
  import { oneLine } from "./frame.js";
4
4
  /** UI §2: Render compact wall-clock durations; never present these as charged active time. */
5
5
  export function duration(ms) {
@@ -72,8 +72,8 @@ export function plainReason(reason) {
72
72
  return `that subagent already finished${detail}`;
73
73
  if (code === "finished")
74
74
  return `that subagent already finished${detail}; use f to follow up`;
75
- if (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")
@@ -142,18 +146,18 @@ export const resultWord = (status) => WORDS[status] ?? "failed";
142
146
  const WORD_ORDER = ["done", "stopped", "failed", "timeout", "budget", "unknown", "skipped", "parked"];
143
147
  /** UI §1,4, v12 §4: One working sentence; done/total uses planned totals, `n+` while a script workflow keeps proposing. */
144
148
  export function summary(workflows) {
145
- const running = workflows.filter(w => w.status === "running"), open = (w) => w.calls.filter(c => c.phase !== "sealed").length;
146
- const asking = running.reduce((n, w) => n + w.calls.filter(c => c.phase !== "sealed" && w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
149
+ const running = workflows.filter(isLive), open = (w) => liveCalls(w).length;
150
+ const asking = running.reduce((n, w) => n + liveCalls(w).filter(c => w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
147
151
  const paused = running.filter(w => w.paused).reduce((n, w) => n + open(w), 0); // held work is not working: it burns nothing
148
152
  // Waiting for a slot is not working either: nothing is spent until it launches.
149
- const queued = running.filter(w => !w.paused).reduce((n, w) => n + w.calls.filter(c => c.phase === "queued" && !w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
153
+ const queued = running.filter(w => !w.paused).reduce((n, w) => n + liveCalls(w).filter(c => c.phase === "queued" && !w.attention.some(a => a.kind === "question" && a.call === c.callId)).length, 0);
150
154
  const progress = running.map(progressOf);
151
155
  return { working: running.reduce((n, w) => n + open(w), 0) - asking - paused - queued, asking, queued, paused,
152
156
  done: progress.reduce((n, p) => n + p.done, 0), total: progress.reduce((n, p) => n + p.total, 0), plus: progress.some(p => p.plus) };
153
157
  }
154
158
  /** UI §1, v12 §4: What needs you first, then what is running, then progress — e.g. "1 asking · 3 working · 12/40 done". */
155
159
  export function summaryText(workflows) {
156
- const s = summary(workflows), finished = workflows.filter(w => w.status !== "running").length;
160
+ const s = summary(workflows), finished = workflows.filter(w => !isLive(w)).length;
157
161
  if (!s.total)
158
162
  return finished ? `${finished} finished` : "nothing running";
159
163
  return [s.asking ? `${s.asking} asking` : "", s.working ? `${s.working} working` : "", s.queued ? `${s.queued} queued` : "", s.paused ? `${s.paused} paused` : "", `${s.done}/${s.total}${s.plus ? "+" : ""} done`].filter(Boolean).join(" · ");
@@ -165,7 +169,7 @@ export function mainLine(workflows) {
165
169
  // The key that opens the list is spelled out: a bare arrow is easy to miss.
166
170
  if (s.working || s.asking || s.queued || s.paused)
167
171
  return `${summaryText(workflows)} · ↓ subagents`;
168
- const w = workflows.filter(w => w.status !== "running").sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
172
+ const w = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
169
173
  if (!w)
170
174
  return undefined;
171
175
  const latest = [...new Map(w.calls.map(c => [c.key, c])).values()]; // the newest generation of each key (P37)
@@ -184,15 +188,16 @@ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
184
188
  * then one summary line; afterwards only the completion sentence for ten minutes; otherwise nothing. Activity spins
185
189
  * only while there is fresh evidence, so a quiet agent visibly stops moving. */
186
190
  export function dockLines(workflows, facts, name, width, now = Date.now(), rows = 3) {
187
- const live = workflows.filter(w => w.status === "running");
191
+ const live = workflows.filter(isLive);
188
192
  const active = live.flatMap(w => {
189
193
  const latest = new Map();
190
194
  for (const c of w.calls)
191
195
  latest.set(c.key, c);
192
- return [...latest.values()].filter(c => c.phase !== "sealed").map(c => ({ w, c, asking: w.attention.some(a => a.kind === "question" && a.call === c.callId) }));
196
+ const open = new Set(liveCalls(w));
197
+ return [...latest.values()].filter(c => open.has(c)).map(c => ({ w, c, asking: w.attention.some(a => a.kind === "question" && a.call === c.callId) }));
193
198
  }).sort((a, b) => Number(b.asking) - Number(a.asking));
194
199
  if (!active.length) {
195
- const ended = workflows.filter(w => w.status !== "running").sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
200
+ const ended = workflows.filter(w => !isLive(w)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0];
196
201
  const line = ended && now - (ended.endedAt ?? 0) < 10 * 60_000 ? mainLine(workflows) : undefined;
197
202
  return line ? [truncateToWidth(line, Math.max(1, width))] : [];
198
203
  }
@@ -213,7 +218,7 @@ export function dockLines(workflows, facts, name, width, now = Date.now(), rows
213
218
  * keep unviewed failures visible, and page newest done rows eight at a time. */
214
219
  /** v12 §5: Whether a workflow shows its agents: running ones unless folded; finished ones once opened (Enter, or on completion). */
215
220
  export function isOpen(w, state) {
216
- return !state.folded.has(w.wid) && (w.status === "running" || (state.done.get(w.wid) ?? 0) > 0);
221
+ return !state.folded.has(w.wid) && (isLive(w) || (state.done.get(w.wid) ?? 0) > 0);
217
222
  }
218
223
  /** v12 §5: Enter on a workflow row opens or closes it. */
219
224
  export function toggleOpen(w, state) {
@@ -221,7 +226,7 @@ export function toggleOpen(w, state) {
221
226
  state.folded.add(w.wid);
222
227
  else {
223
228
  state.folded.delete(w.wid);
224
- if (w.status !== "running")
229
+ if (!isLive(w))
225
230
  state.done.set(w.wid, Math.max(8, state.done.get(w.wid) ?? 0));
226
231
  }
227
232
  }
@@ -263,13 +268,13 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
263
268
  for (const c of v.calls)
264
269
  latest.set(c.key, c);
265
270
  const w = latest.size === v.calls.length ? v : { ...v, calls: v.calls.filter(c => latest.get(c.key) === c) };
266
- const dim = w.status !== "running"; // v12 §5: finished workflows are dimmed, not hidden
271
+ const dim = !isLive(w); // v12 §5: finished workflows are dimmed, not hidden; an open follow-up keeps one live
267
272
  if (w.calls.length === 1) {
268
273
  callRow(w, w.calls[0], " ", " ", dim);
269
274
  continue;
270
275
  }
271
276
  // Stable order (UI §2): active rows keep proposal (snapshot) order; done rows by immutable end time.
272
- const done = doneOrder(w.calls), active = w.calls.filter(c => c.phase !== "sealed"), folded = !isOpen(w, state);
277
+ const done = doneOrder(w.calls), active = liveCalls(w), folded = !isOpen(w, state);
273
278
  const progress = progressOf(w); // v12 §4: done/planned, `n+` while a script workflow keeps proposing
274
279
  rows.push({ id: w.wid, kind: "workflow", workflow: w, dim, text: `${folded ? "▸" : "▾"} ${w.name ?? w.wid} · ${progress.done}/${progress.total}${progress.plus ? "+" : ""} · ${duration((w.endedAt ?? now) - (w.startedAt ?? now))}` });
275
280
  if (folded)
@@ -281,7 +286,7 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
281
286
  active.forEach((c, i) => { const last = i === active.length - 1 && !done.length; callRow(w, c, last ? " └ " : " ├ ", last ? " " : " │ ", dim); });
282
287
  if (!done.length)
283
288
  continue;
284
- const count = state.done.get(w.wid) ?? (w.status === "running" && !active.length || unviewed(w) ? 8 : 0), shown = done.slice(0, count);
289
+ const count = state.done.get(w.wid) ?? (isLive(w) && !active.length || unviewed(w) ? 8 : 0), shown = done.slice(0, count);
285
290
  rows.push({ id: `${w.wid}:done`, kind: "done", workflow: w, dim, text: ` └ ${count ? "▾" : "▸"} ${progress.done} done` });
286
291
  const more = count && done.length > count;
287
292
  shown.forEach((c, i) => callRow(w, c, i === shown.length - 1 && !more ? " └ " : " ├ ", undefined, dim));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",