pi-durable-subagents 1.0.5 → 1.0.7

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.7
4
+
5
+ - Changes to `config.json` (`defaultModel`, `pools`, `providers`, `memory`,
6
+ `k`) apply without restarting the orchestrator: it checks the file about
7
+ once a second and applies a valid change between slot admissions, so a call
8
+ still waiting for a slot follows the new model, pool or limit. Slots already
9
+ held are kept when a limit drops. An invalid change is refused and the
10
+ settings in effect stay; `status` names the error until the file is valid
11
+ again.
12
+ - `status` marks an asker whose execution hibernated with `hibernated: true`
13
+ (CLI: "hibernated, no slot"): it holds no provider slot until it is
14
+ answered. Both `status` forms list provider slots in use against their
15
+ limits, and the config in effect.
16
+
17
+ ## 1.0.6
18
+
19
+ - A follow-up on a finished workflow shows as running work: in the dock, the
20
+ summary line, the list (no longer dimmed or hidden) and `status`. Quitting pi
21
+ pauses it, and `resume` continues it; before, a resume refused it as finished.
22
+ - ↓ opens the subagent list only from pi's input editor. With `/model`'s
23
+ selector, a dialog or another overlay in focus, ↓ moves in that list again.
24
+
3
25
  ## 1.0.5
4
26
 
5
27
  - `status` without a wid is brief: what runs, what asks (with the address to
package/README.md CHANGED
@@ -273,6 +273,11 @@ State lives in `~/.pi/durable-subagents`; set `DSA_HOME` to move it.
273
273
  never stopped for memory.
274
274
  - **onQuit:** `"pause"` (default) pauses a session's running workflows when
275
275
  you quit that pi; `"continue"` lets them run on in the background.
276
+ - **Changes apply without a restart:** the orchestrator re-reads the file
277
+ when it changes. A new slot limit, pool or default model applies to the next
278
+ slot acquisition; slots already held are kept when a limit drops. An
279
+ invalid change is not applied, and `status` reports it next to the settings
280
+ still in effect (`config: <hash> since …`) and the slots held per provider.
276
281
 
277
282
  ## Switching back
278
283
 
@@ -12,7 +12,7 @@ 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
14
  import { attention, presentText, presented, resolved, unfinishedWorkflow } from "./main/snapshots.js";
15
- import { pausedElsewhere, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.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;
@@ -165,7 +165,7 @@ export function registerMain(pi, ui) {
165
165
  pi.on("session_start", async (_event, context) => {
166
166
  await start(context);
167
167
  // Quit pause: say once, at the session's start, which of its workflows wait for a resume.
168
- 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);
169
169
  if (paused.length)
170
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.` });
171
171
  });
@@ -176,7 +176,7 @@ export function registerMain(pi, ui) {
176
176
  pi.on("session_shutdown", async (event) => {
177
177
  if (event.reason === "quit" && quitPolicy(home) === "pause" && outbox && !stopped) {
178
178
  try {
179
- 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);
180
180
  if (running)
181
181
  await serial(async () => { await outbox?.send("orch", "drain", { fence: true, origin: sender }); });
182
182
  }
@@ -325,7 +325,7 @@ export function registerMain(pi, ui) {
325
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.",
326
326
  "agents: list names, descriptions, default models and source for this cwd; use these names for run.",
327
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.",
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; hibernated:true holds no slot) or failed, finished workflows one line each, provider slots held/limit and the config in effect; 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.",
329
329
  "Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
330
330
  ...(agents ? [`Available agents: ${agents}.`] : []),
331
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.",
package/dist/cli/main.js CHANGED
@@ -64,14 +64,17 @@ 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)}` : ""}`,
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})` : ""}`),
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
+ ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.hibernated ? " (hibernated, no slot)" : ""}${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)
71
71
  lines.unshift(`${view.paused} (pi-durable-subagents resume)`);
72
72
  if (view.olderFinished)
73
73
  lines.push(`(+${view.olderFinished} older finished workflows; status <wid> shows one in detail)`);
74
- return lines.join("\n") || "No workflows";
74
+ const footer = [view.slots?.length ? `slots: ${view.slots.join(", ")}` : "", view.config ? `config: ${view.config}` : "", view.configRejected ? `config.json rejected: ${view.configRejected}` : ""].filter(Boolean);
75
+ if (!lines.length)
76
+ lines.push("No workflows");
77
+ return [...lines, ...footer].join("\n");
75
78
  }
76
79
  function snapshots(home, wid) {
77
80
  if (!wid)
@@ -0,0 +1,147 @@
1
+ // $DSA_HOME/config.json for the orchestrator: read at start and again whenever the file changes, so a new slot limit,
2
+ // pool order or K-parameter needs no orchestrator restart (a restart at the wrong moment used to cost a dispatch).
3
+ // Orchestrator ledger entries: config{hash,config} — the settings in effect from then on (at start, or after a change);
4
+ // config-rejected{hash,error} — a changed file that was not applied; the settings before it stay in effect.
5
+ // A reload changes the shared config object in place: every later read sees it (the next slot acquisition, model
6
+ // resolution or check). Slots already held are kept when a limit drops; timers of running executions keep their period.
7
+ import { readFile, stat } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ import { contentHash } from "../kernel/ids.js";
10
+ /** The keys the orchestrator reads; config.json also holds pi-side settings (ui, onQuit) that it ignores. */
11
+ const KEYS = ["defaultModel", "pools", "providers", "memory", "k"];
12
+ const K = ["lossBound", "checkpointMs", "stallMs", "progressMs", "switchTimeoutMs", "idleExitMs", "trackerMs", "hibernateMs", "spawnBudget"];
13
+ export const configPath = (home) => join(home, "config.json");
14
+ /** The orchestrator's part of a parsed config.json. */
15
+ export function orchestratorSettings(raw) {
16
+ const value = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
17
+ return Object.fromEntries(KEYS.filter(k => value[k] !== undefined).map(k => [k, value[k]]));
18
+ }
19
+ export const configHash = (config) => contentHash(orchestratorSettings(config)).slice(0, 12);
20
+ const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
21
+ const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
22
+ /** What is wrong with a parsed config.json for the orchestrator, or undefined. Unknown keys are allowed. */
23
+ export function configProblem(raw) {
24
+ if (!record(raw))
25
+ return "config.json must be a JSON object";
26
+ const { defaultModel, pools, providers, memory, k } = raw;
27
+ if (defaultModel !== undefined && typeof defaultModel !== "string")
28
+ return "defaultModel must be a string";
29
+ if (pools !== undefined) {
30
+ if (!record(pools))
31
+ return "pools must map names to model lists";
32
+ for (const [name, list] of Object.entries(pools))
33
+ if (!Array.isArray(list) || !list.length || !list.every(m => typeof m === "string" && m))
34
+ return `pools.${name} must be a nonempty list of "provider/id[:thinking]"`;
35
+ }
36
+ if (providers !== undefined) {
37
+ if (!record(providers))
38
+ return "providers must map provider names to { slots }";
39
+ for (const [name, p] of Object.entries(providers))
40
+ if (!record(p) || !Number.isSafeInteger(p.slots) || Number(p.slots) < 0)
41
+ return `providers.${name}.slots must be a nonnegative integer`;
42
+ }
43
+ if (memory !== undefined) {
44
+ if (!record(memory))
45
+ return "memory must be { reserveMb?, perChildMb? }";
46
+ for (const key of ["reserveMb", "perChildMb"])
47
+ if (memory[key] !== undefined && !count(memory[key]))
48
+ return `memory.${key} must be a nonnegative number`;
49
+ }
50
+ if (k !== undefined) {
51
+ if (!record(k))
52
+ return "k must map K-parameters to numbers";
53
+ for (const [key, v] of Object.entries(k)) {
54
+ if (!K.includes(key))
55
+ return `k.${key} is not a K-parameter (${K.join(", ")})`;
56
+ if (!count(v) || (key !== "lossBound" && key !== "spawnBudget" && v === 0))
57
+ return `k.${key} must be a positive number`;
58
+ }
59
+ }
60
+ }
61
+ /** Parse config.json; a missing file is the empty config. A JSON error throws. */
62
+ export async function readConfig(path) {
63
+ try {
64
+ return JSON.parse(await readFile(path, "utf8"));
65
+ }
66
+ catch (error) {
67
+ if (error.code === "ENOENT")
68
+ return {};
69
+ throw error;
70
+ }
71
+ }
72
+ /** Record the settings in effect unless the ledger already ends on the same ones (a restart with an unchanged file).
73
+ * After a rejection they are recorded again: that confirms the file is back to settings in effect. */
74
+ export async function recordConfig(orch, config) {
75
+ const hash = configHash(config), last = orch.entries().findLast(e => e.type === "config" || e.type === "config-rejected");
76
+ if (last?.type !== "config" || last.hash !== hash)
77
+ await orch.append("config", { hash, config: orchestratorSettings(config) });
78
+ return hash;
79
+ }
80
+ /** Replace the contents of the shared config object; readers hold the object, not a copy. */
81
+ function applyInPlace(target, next) {
82
+ const t = target;
83
+ for (const key of KEYS)
84
+ delete t[key];
85
+ Object.assign(t, orchestratorSettings(next));
86
+ }
87
+ /** Identity of the file's current version (taken before a read, so a change during the read is seen next time). */
88
+ export async function configStamp(path) {
89
+ try {
90
+ const s = await stat(path);
91
+ return `${s.ino}:${s.size}:${s.mtimeMs}`;
92
+ }
93
+ catch (error) {
94
+ if (error.code === "ENOENT")
95
+ return "missing";
96
+ throw error;
97
+ }
98
+ }
99
+ /** Watch config.json from the version `stamp` (read at start) and apply each valid change in place. */
100
+ /** `apply` runs the change where readers cannot observe half of it (the executor's admission section). */
101
+ export function watchConfig(options) {
102
+ const { path, config, orch } = options;
103
+ let stamp = options.stamp, running, rejected, stopped = false;
104
+ const once = async () => {
105
+ const now = await configStamp(path);
106
+ if (now === stamp || stopped)
107
+ return;
108
+ let raw;
109
+ try {
110
+ raw = await readConfig(path);
111
+ }
112
+ catch (error) {
113
+ // A half-written file reads as invalid JSON: retry on the next change of the file, report it once per version.
114
+ stamp = now;
115
+ const hash = `invalid:${now}`;
116
+ if (rejected !== hash) {
117
+ rejected = hash;
118
+ await orch.append("config-rejected", { error: `config.json is not valid JSON: ${error.message}` });
119
+ }
120
+ return;
121
+ }
122
+ stamp = now;
123
+ // Validate the content first: `[]` or `null` project to no settings and must not pass as "unchanged".
124
+ const problem = configProblem(raw), next = orchestratorSettings(raw), hash = configHash(next);
125
+ if (problem) {
126
+ if (rejected !== hash) {
127
+ rejected = hash;
128
+ await orch.append("config-rejected", { hash, error: problem });
129
+ }
130
+ return;
131
+ }
132
+ rejected = undefined;
133
+ let changed = false;
134
+ await (options.apply ?? (change => change()))(async () => {
135
+ changed = hash !== configHash(config);
136
+ if (changed)
137
+ applyInPlace(config, next);
138
+ await recordConfig(orch, config); // also confirms a return to the settings in effect after a rejection
139
+ });
140
+ if (changed)
141
+ options.onApplied?.();
142
+ };
143
+ const check = () => running ??= once().catch(error => console.error(`durable-subagents: config.json check failed: ${String(error)}`)).finally(() => { running = undefined; });
144
+ const timer = setInterval(check, options.intervalMs ?? 1000);
145
+ timer.unref();
146
+ return { stop: async () => { stopped = true; clearInterval(timer); await running; }, check };
147
+ }
@@ -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';
@@ -389,13 +389,18 @@ export default function createExecutor(ledgers, options = {}) {
389
389
  }
390
390
  const stopped = (a) => a.stopped || entriesFor(a.ticket.journal, a.ticket.callId).some(e => e.type === "stop-intent");
391
391
  const interrupted = (a) => closed || a.suspended || a.retired || stopped(a);
392
- async function acquire(a, exec, models, pool, continuation = false) {
392
+ /** Wait for a slot. The candidates are decided again on every attempt, inside the serial section that also applies
393
+ * config.json changes: a queued call follows a reloaded defaultModel, pool or limit, never a mix of two versions. */
394
+ async function acquire(a, exec, decide) {
395
+ let continuation = false;
393
396
  for (;;) {
394
397
  let signal;
395
398
  const changed = new Promise(resolve => { signal = resolve; waiters.add(resolve); });
396
399
  const chosen = await serial(async () => {
397
400
  if (interrupted(a) || await workflowReached(a.ticket))
398
401
  return;
402
+ const decision = await decide(), models = decision.candidates, pool = decision.pool;
403
+ continuation = decision.continuation;
399
404
  for (const model of models) {
400
405
  if (!continuation && pool && skipped(pool, model))
401
406
  continue;
@@ -434,7 +439,7 @@ export default function createExecutor(ledgers, options = {}) {
434
439
  });
435
440
  if (chosen || interrupted(a) || reached(totalUsage(a.ticket.journal.entries()), a.ticket.workflowBudget)) {
436
441
  waiters.delete(signal);
437
- return chosen;
442
+ return { model: chosen, continuation };
438
443
  }
439
444
  const timer = setTimeout(signal, config.k?.trackerMs ?? 1000);
440
445
  try {
@@ -599,13 +604,13 @@ export default function createExecutor(ledgers, options = {}) {
599
604
  }
600
605
  let decision;
601
606
  try {
602
- decision = await launchModel(t, entries, previous);
607
+ decision = await acquire(a, exec, () => launchModel(t, entries, previous));
603
608
  }
604
609
  catch (error) {
605
610
  await fence(journal, exec, { park: a });
606
611
  return finish(journal, t.callId, exec, makeResult("failed", "", String(error)));
607
612
  }
608
- const model = await acquire(a, exec, decision.candidates, decision.pool, decision.continuation);
613
+ const model = decision.model;
609
614
  if (!model || interrupted(a)) {
610
615
  await fence(journal, exec, { park: a });
611
616
  return finish(journal, t.callId, exec, !interrupted(a) ? makeResult("failed", "", "workflow budget reached") : makeResult("stopped"));
@@ -687,6 +692,7 @@ export default function createExecutor(ledgers, options = {}) {
687
692
  await (await outbox).send(envelope.to, envelope.kind, envelope.body, envelope.cond, { rid: String(e.rid2) });
688
693
  }
689
694
  return {
695
+ async reconfigure(apply) { await serial(apply); wake(); },
690
696
  run(ticket) {
691
697
  const existing = completed.get(ticket.callId);
692
698
  if (existing)
@@ -123,7 +123,7 @@ export async function observeExecution(d) {
123
123
  }
124
124
  });
125
125
  });
126
- let scanning = false, scanFailures = 0;
126
+ let scanning = false, failingSince;
127
127
  const scan = () => {
128
128
  if (scanning)
129
129
  return;
@@ -133,12 +133,14 @@ export async function observeExecution(d) {
133
133
  // One failed process-table scan is no evidence, not a reason to fence a live child: on macOS `ps` can miss its
134
134
  // 2 s deadline right after the orchestrator was stopped or the machine slept (seen on CI as a spurious loss and
135
135
  // a rerun). Only a scan that keeps failing for a minute ends the observation.
136
+ // Measured in elapsed time: the scan period is fixed at start while k.trackerMs can be reloaded.
136
137
  try {
137
138
  clock.scan(await d.track());
138
- scanFailures = 0;
139
+ failingSince = undefined;
139
140
  }
140
141
  catch (error) {
141
- if (++scanFailures * (config.k?.trackerMs ?? 1000) >= 60_000)
142
+ failingSince ??= performance.now();
143
+ if (performance.now() - failingSince >= 60_000)
142
144
  throw error;
143
145
  }
144
146
  const nextSize = (await fileStat(session).catch(() => ({ size: 0 }))).size;
@@ -6,7 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import { mkdir, readFile } from 'node:fs/promises';
9
+ import { mkdir } from 'node:fs/promises';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { realpathSync } from 'node:fs';
12
12
  import { join, resolve } from 'node:path';
@@ -14,6 +14,7 @@ import { dsaHome, orchLedger, orchLock } from "../paths.js";
14
14
  import { openJournal } from "../kernel/journal.js";
15
15
  import { OsLock } from "../platform/lock.js";
16
16
  import { Engine } from "./engine.js";
17
+ import { configPath, configProblem, configStamp, readConfig, recordConfig, watchConfig } from "./config.js";
17
18
  /** P2, C4, K6: Acquire the single authority before opening ledgers, recover, and idle-exit. */
18
19
  export async function main(options = {}) {
19
20
  const home = options.home ?? dsaHome();
@@ -21,27 +22,34 @@ export async function main(options = {}) {
21
22
  const lock = await new OsLock().tryAcquire(orchLock(home));
22
23
  if (!lock)
23
24
  return;
24
- let engine, ledgers;
25
+ let engine, ledgers, watcher;
25
26
  try {
26
- let config = options.config;
27
+ let config = options.config, stamp;
27
28
  if (!config) {
28
- try {
29
- config = JSON.parse(await readFile(join(home, 'config.json'), 'utf8'));
30
- }
31
- catch (error) {
32
- if (error.code !== 'ENOENT')
33
- throw error;
34
- config = {};
35
- }
29
+ // The stamp is taken before the read: a change made while reading is applied by the first check.
30
+ stamp = await configStamp(configPath(home));
31
+ const raw = await readConfig(configPath(home));
32
+ const problem = configProblem(raw);
33
+ if (problem)
34
+ console.error(`durable-subagents: config.json: ${problem}`);
35
+ config = raw;
36
36
  }
37
37
  ledgers = { home, config, orch: await openJournal(orchLedger(home)) };
38
38
  const factory = options.executor ?? (await import(__rewriteRelativeImportExtension(new URL(import.meta.url.endsWith('.ts') ? './executor/index.ts' : './executor/index.js', import.meta.url).href))).default;
39
- engine = new Engine(ledgers, factory(ledgers), options);
39
+ const executor = factory(ledgers);
40
+ engine = new Engine(ledgers, executor, options);
41
+ if (stamp !== undefined) {
42
+ await recordConfig(ledgers.orch, config);
43
+ // A change applies between slot admissions, so one admission never mixes two versions of the limits.
44
+ watcher = watchConfig({ path: configPath(home), stamp, config, orch: ledgers.orch, intervalMs: Math.min(1000, config.k?.trackerMs ?? 1000),
45
+ apply: change => executor.reconfigure ? executor.reconfigure(change) : change() });
46
+ }
40
47
  await engine.recover();
41
48
  await engine.loop(options.signal);
42
49
  }
43
50
  finally {
44
51
  try {
52
+ await watcher?.stop();
45
53
  await engine?.close();
46
54
  }
47
55
  finally {
@@ -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) {
@@ -68,7 +76,9 @@ export function snapshotFromEntries(wid, entries) {
68
76
  const done = terminal?.type === JT.done ? terminal : undefined;
69
77
  const calls = new Map();
70
78
  const byExec = new Map();
79
+ const hibernating = new Map(); // callId → exec that decided to hibernate and is not fenced yet
71
80
  const resolved = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}@${e.rev}`));
81
+ const generations = new Set(), retired = new Set();
72
82
  const attention = [];
73
83
  // P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
74
84
  const live = new Map(), sealedUsage = new Map(), seen = new Set();
@@ -81,6 +91,8 @@ export function snapshotFromEntries(wid, entries) {
81
91
  continue;
82
92
  const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
83
93
  const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
94
+ if (e.type === "generation")
95
+ generations.add(callId);
84
96
  const result = e.type === "refused" ? refusedResult(key, e.reason) :
85
97
  e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
86
98
  const wanted = e.spec?.model;
@@ -90,6 +102,9 @@ export function snapshotFromEntries(wid, entries) {
90
102
  phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
91
103
  ...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
92
104
  }
105
+ else if (e.type === "retired") {
106
+ retired.add(String(e.call));
107
+ }
93
108
  else if (e.type === JT.exec) {
94
109
  const call = calls.get(String(e.call));
95
110
  if (!call)
@@ -101,12 +116,29 @@ export function snapshotFromEntries(wid, entries) {
101
116
  call.startedAt ??= e.ts;
102
117
  call.lastActivity = e.ts;
103
118
  byExec.set(call.exec, call);
119
+ delete call.hibernated;
120
+ hibernating.delete(call.callId);
121
+ }
122
+ else if (e.type === "hibernated") {
123
+ // The decision to hibernate precedes the fence; the slot is released only once the execution is fenced.
124
+ const call = calls.get(String(e.call));
125
+ if (call?.exec && call.exec === e.exec)
126
+ hibernating.set(call.callId, call.exec);
127
+ }
128
+ else if (e.type === "answer-bound" || (e.type === "resumed" && e.call)) {
129
+ const call = calls.get(String(e.call));
130
+ if (call) {
131
+ delete call.hibernated;
132
+ hibernating.delete(call.callId);
133
+ }
104
134
  }
105
135
  else if (e.type === JT.fenced) {
106
136
  // A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
107
137
  const call = byExec.get(String(e.exec));
108
138
  if (call && call.phase === "running")
109
139
  call.phase = "queued";
140
+ if (call && hibernating.get(call.callId) === e.exec)
141
+ call.hibernated = true;
110
142
  }
111
143
  else if (e.type === "selected") {
112
144
  const call = byExec.get(String(e.exec)), m = e.model;
@@ -144,6 +176,8 @@ export function snapshotFromEntries(wid, entries) {
144
176
  call.phase = "sealed";
145
177
  call.result = e.result;
146
178
  call.endedAt = e.ts;
179
+ delete call.hibernated;
180
+ hibernating.delete(call.callId);
147
181
  }
148
182
  else if (e.type === JT.attention) {
149
183
  const item = e.item;
@@ -175,6 +209,9 @@ export function snapshotFromEntries(wid, entries) {
175
209
  if (call && (call.phase === "running" || call.phase === "queued"))
176
210
  call.phase = "asking"; // a hibernated asker is fenced
177
211
  }
212
+ for (const c of calls.values())
213
+ if (c.hibernated && c.phase !== "asking")
214
+ delete c.hibernated;
178
215
  const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
179
216
  const list = [...calls.values()];
180
217
  const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
@@ -197,6 +234,10 @@ export function snapshotFromEntries(wid, entries) {
197
234
  c.switching = switching;
198
235
  }
199
236
  }
237
+ const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
238
+ for (const c of after)
239
+ c.afterEnd = true;
240
+ const followUps = after.length;
200
241
  const usage = zero();
201
242
  for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
202
243
  const u = usageOf(id);
@@ -210,6 +251,7 @@ export function snapshotFromEntries(wid, entries) {
210
251
  status: done ? done.status : "running",
211
252
  ...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
212
253
  ...(done ? { endedAt: done.ts } : {}),
254
+ ...(followUps ? { followUps } : {}),
213
255
  calls: list, counts, attention, usage,
214
256
  };
215
257
  }
@@ -262,7 +304,7 @@ export function heldWorkflows(home) {
262
304
  /** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
263
305
  export function allWorkflows(home) {
264
306
  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; });
307
+ return workflowIds(home).sort().reverse().map(wid => { const wf = workflowSnapshot(home, wid); return isLive(wf) && held(wid) ? { ...wf, paused: true } : wf; });
266
308
  }
267
309
  /** P10/P11: Per-workflow script console log written by the orchestrator (bounded, human-readable). */
268
310
  export function scriptLogPath(home, wid) { return join(workflowDir(home, wid), "script.log"); }
@@ -283,10 +325,10 @@ export function compactWorkflow(wf) {
283
325
  const r = c.result, last = r?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1);
284
326
  return { key: c.key, gen: c.gen, callId: c.callId, phase: c.phase, ...(r ? { status: r.status, ok: r.ok } : {}),
285
327
  ...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
286
- ...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}) };
328
+ ...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}), ...(c.hibernated ? { hibernated: true } : {}) };
287
329
  }),
288
330
  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 } : {}),
331
+ ...(wf.paused ? { paused: true } : {}), ...(wf.followUps ? { followUps: wf.followUps } : {}),
290
332
  };
291
333
  }
292
334
  function origins(home) {
@@ -306,7 +348,7 @@ function snapshots(home, origin) {
306
348
  const { since, held } = heldWorkflows(home);
307
349
  const all = [...origins(home)].sort(([a], [b]) => a < b ? 1 : a > b ? -1 : 0).map(([wid, origin]) => {
308
350
  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;
351
+ return isLive(withOrigin) && held(wid) ? { ...withOrigin, paused: true } : withOrigin;
310
352
  }).sort((a, b) => own(b) - own(a));
311
353
  return { all, ...(since !== undefined ? { since } : {}) };
312
354
  }
@@ -315,17 +357,47 @@ export function statusView(home, options = {}) {
315
357
  const keep = options.keep ?? 10;
316
358
  const { all, since } = snapshots(home, options.origin);
317
359
  let finished = 0;
318
- const shown = all.filter(w => !["done", "failed", "stopped"].includes(w.status) || ++finished <= keep);
360
+ const shown = all.filter(w => !settled(w) || ++finished <= keep);
319
361
  const hidden = all.length - shown.length;
320
362
  const paused = all.filter(w => w.paused).length;
321
363
  return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
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)` } : {}) };
364
+ ...(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)` } : {}),
365
+ ...slotsView(home) };
323
366
  }
324
367
  const FINAL = ["done", "failed", "stopped"];
368
+ /** Finished with nothing left running (a follow-up on a finished workflow is live work). */
369
+ const settled = (w) => FINAL.includes(w.status) && !w.followUps;
325
370
  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
371
  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
372
  /** The latest generation of every key, in first-call order. */
328
373
  const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()];
374
+ /** Provider slots from the orchestrator ledger: holders per provider (hold/release{pool,slot,exec}) and the limits of
375
+ * the settings in effect (the latest config{hash,config}); config-rejected after it is reported too. */
376
+ export function slotsView(home, now = Date.now()) {
377
+ const held = new Map();
378
+ let config, rejected;
379
+ for (const e of readJournalSnapshot(orchLedger(home))) {
380
+ if (e.type === "hold")
381
+ held.set(`${e.pool}:${e.slot}`, e);
382
+ else if (e.type === "release" && held.get(`${e.pool}:${e.slot}`)?.exec === e.exec)
383
+ held.delete(`${e.pool}:${e.slot}`);
384
+ else if (e.type === "config") {
385
+ config = e;
386
+ rejected = undefined;
387
+ }
388
+ else if (e.type === "config-rejected")
389
+ rejected = e;
390
+ }
391
+ const limits = (config?.config?.providers) ?? {};
392
+ const holders = new Map();
393
+ for (const e of held.values())
394
+ if (e.pool !== "memory")
395
+ holders.set(String(e.pool), (holders.get(String(e.pool)) ?? 0) + 1);
396
+ const names = [...new Set([...Object.keys(limits), ...holders.keys()])].sort();
397
+ const slots = names.map(p => { const n = holders.get(p) ?? 0, limit = limits[p]?.slots; return typeof limit === "number" ? `${p} ${n}/${limit}` : `${p} ${n} (no limit)`; });
398
+ return { ...(slots.length ? { slots } : {}), ...(config ? { config: `${String(config.hash)} since ${age(now - config.ts)} ago` } : {}),
399
+ ...(rejected ? { configRejected: `${clip(String(rejected.error), 200)} (${age(now - rejected.ts)} ago); ${config ? String(config.hash) : "the start settings"} stay in effect` } : {}) };
400
+ }
329
401
  /** Tool status without a wid: what runs, what waits for an answer and what failed, with finished workflows one line each.
330
402
  * The full view (every call's last line and usage) ran to tens of thousands of tokens on a busy home. */
331
403
  export function statusBrief(home, options = {}) {
@@ -334,7 +406,7 @@ export function statusBrief(home, options = {}) {
334
406
  const mine = (w) => !origin || w.origin === origin;
335
407
  const line = (w) => {
336
408
  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` : "",
409
+ 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` : "",
338
410
  w.endedAt !== undefined ? `ended ${age(now - w.endedAt)} ago` : w.startedAt !== undefined ? `started ${age(now - w.startedAt)} ago` : ""].filter(Boolean).join(" · ");
339
411
  };
340
412
  const brief = (w) => {
@@ -345,16 +417,18 @@ export function statusBrief(home, options = {}) {
345
417
  ...(live && c.startedAt !== undefined ? { for: age(now - c.startedAt) } : {}),
346
418
  ...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
347
419
  ...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
348
- ...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}) };
420
+ ...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}),
421
+ ...(c.hibernated ? { hibernated: true } : {}) };
349
422
  });
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) }));
423
+ const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
424
+ ...(w.calls.some(c => c.callId === a.call && c.hibernated) ? { hibernated: true } : {}), question: clip(a.text, 300) }));
351
425
  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 } : {}),
426
+ return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}), ...(w.followUps ? { followUps: w.followUps } : {}),
353
427
  progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
354
428
  ...(asking.length ? { asking } : {}), ...(alerts.length ? { alerts } : {}) };
355
429
  };
356
- const active = all.filter(w => !FINAL.includes(w.status));
357
- const finished = all.filter(w => FINAL.includes(w.status) && mine(w));
430
+ const active = all.filter(w => !settled(w));
431
+ const finished = all.filter(w => settled(w) && mine(w));
358
432
  const others = active.filter(w => !mine(w));
359
433
  const heldMine = active.filter(w => mine(w) && w.paused).length, heldOthers = others.filter(w => w.paused);
360
434
  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"}` : "",
@@ -362,13 +436,13 @@ export function statusBrief(home, options = {}) {
362
436
  return {
363
437
  active: active.filter(mine).map(brief), ...(others.length ? { otherSessions: others.slice(0, 10).map(line) } : {}),
364
438
  finished: finished.slice(0, keep).map(line), ...(finished.length > keep ? { olderFinished: finished.length - keep } : {}),
365
- ...(paused ? { paused } : {}),
439
+ ...(paused ? { paused } : {}), ...slotsView(home, now),
366
440
  hint: "status wid=<wid> shows one workflow (outputs clipped); add key=<key> for one call's full result, or full:true for everything",
367
441
  };
368
442
  }
369
443
  /** Running workflows of other sessions that a pause holds, as "wid" or "wid (name)": a session's own resume skips them. */
370
444
  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);
445
+ return snapshots(home, origin).all.filter(w => w.paused && w.origin !== origin && !settled(w)).map(w => w.name ? `${w.wid} (${w.name})` : w.wid);
372
446
  }
373
447
  /** "<rid>" or "<rid>/<rest>" of a run request that created a workflow → the same address with its wid; else unchanged.
374
448
  * A run replies {submitted:{rid}} when its workflow is not created within 10 s, so the rid is all the caller has. */
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
@@ -50,7 +50,7 @@ export function resultLines(details) {
50
50
  if (Array.isArray(d.active)) {
51
51
  const rows = d.active.map(w => {
52
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" : ""}` : ""}`;
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
54
  });
55
55
  const finished = Array.isArray(d.finished) ? d.finished.length + Number(d.olderFinished ?? 0) : 0;
56
56
  return [...(typeof d.paused === "string" ? [`⚠ ${d.paused}`] : []), ...(rows.length ? rows.slice(0, 6) : ["nothing running"]),
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) {
@@ -146,18 +146,18 @@ export const resultWord = (status) => WORDS[status] ?? "failed";
146
146
  const WORD_ORDER = ["done", "stopped", "failed", "timeout", "budget", "unknown", "skipped", "parked"];
147
147
  /** UI §1,4, v12 §4: One working sentence; done/total uses planned totals, `n+` while a script workflow keeps proposing. */
148
148
  export function summary(workflows) {
149
- const running = workflows.filter(w => w.status === "running"), open = (w) => w.calls.filter(c => c.phase !== "sealed").length;
150
- 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);
151
151
  const paused = running.filter(w => w.paused).reduce((n, w) => n + open(w), 0); // held work is not working: it burns nothing
152
152
  // Waiting for a slot is not working either: nothing is spent until it launches.
153
- 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);
154
154
  const progress = running.map(progressOf);
155
155
  return { working: running.reduce((n, w) => n + open(w), 0) - asking - paused - queued, asking, queued, paused,
156
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) };
157
157
  }
158
158
  /** UI §1, v12 §4: What needs you first, then what is running, then progress — e.g. "1 asking · 3 working · 12/40 done". */
159
159
  export function summaryText(workflows) {
160
- const s = summary(workflows), finished = workflows.filter(w => w.status !== "running").length;
160
+ const s = summary(workflows), finished = workflows.filter(w => !isLive(w)).length;
161
161
  if (!s.total)
162
162
  return finished ? `${finished} finished` : "nothing running";
163
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(" · ");
@@ -169,7 +169,7 @@ export function mainLine(workflows) {
169
169
  // The key that opens the list is spelled out: a bare arrow is easy to miss.
170
170
  if (s.working || s.asking || s.queued || s.paused)
171
171
  return `${summaryText(workflows)} · ↓ subagents`;
172
- 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];
173
173
  if (!w)
174
174
  return undefined;
175
175
  const latest = [...new Map(w.calls.map(c => [c.key, c])).values()]; // the newest generation of each key (P37)
@@ -188,15 +188,16 @@ const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "
188
188
  * then one summary line; afterwards only the completion sentence for ten minutes; otherwise nothing. Activity spins
189
189
  * only while there is fresh evidence, so a quiet agent visibly stops moving. */
190
190
  export function dockLines(workflows, facts, name, width, now = Date.now(), rows = 3) {
191
- const live = workflows.filter(w => w.status === "running");
191
+ const live = workflows.filter(isLive);
192
192
  const active = live.flatMap(w => {
193
193
  const latest = new Map();
194
194
  for (const c of w.calls)
195
195
  latest.set(c.key, c);
196
- 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) }));
197
198
  }).sort((a, b) => Number(b.asking) - Number(a.asking));
198
199
  if (!active.length) {
199
- 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];
200
201
  const line = ended && now - (ended.endedAt ?? 0) < 10 * 60_000 ? mainLine(workflows) : undefined;
201
202
  return line ? [truncateToWidth(line, Math.max(1, width))] : [];
202
203
  }
@@ -217,7 +218,7 @@ export function dockLines(workflows, facts, name, width, now = Date.now(), rows
217
218
  * keep unviewed failures visible, and page newest done rows eight at a time. */
218
219
  /** v12 §5: Whether a workflow shows its agents: running ones unless folded; finished ones once opened (Enter, or on completion). */
219
220
  export function isOpen(w, state) {
220
- 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);
221
222
  }
222
223
  /** v12 §5: Enter on a workflow row opens or closes it. */
223
224
  export function toggleOpen(w, state) {
@@ -225,7 +226,7 @@ export function toggleOpen(w, state) {
225
226
  state.folded.add(w.wid);
226
227
  else {
227
228
  state.folded.delete(w.wid);
228
- if (w.status !== "running")
229
+ if (!isLive(w))
229
230
  state.done.set(w.wid, Math.max(8, state.done.get(w.wid) ?? 0));
230
231
  }
231
232
  }
@@ -267,13 +268,13 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
267
268
  for (const c of v.calls)
268
269
  latest.set(c.key, c);
269
270
  const w = latest.size === v.calls.length ? v : { ...v, calls: v.calls.filter(c => latest.get(c.key) === c) };
270
- 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
271
272
  if (w.calls.length === 1) {
272
273
  callRow(w, w.calls[0], " ", " ", dim);
273
274
  continue;
274
275
  }
275
276
  // Stable order (UI §2): active rows keep proposal (snapshot) order; done rows by immutable end time.
276
- 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);
277
278
  const progress = progressOf(w); // v12 §4: done/planned, `n+` while a script workflow keeps proposing
278
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))}` });
279
280
  if (folded)
@@ -285,7 +286,7 @@ export function listRows(workflows, state, facts, name, width, now = Date.now())
285
286
  active.forEach((c, i) => { const last = i === active.length - 1 && !done.length; callRow(w, c, last ? " └ " : " ├ ", last ? " " : " │ ", dim); });
286
287
  if (!done.length)
287
288
  continue;
288
- 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);
289
290
  rows.push({ id: `${w.wid}:done`, kind: "done", workflow: w, dim, text: ` └ ${count ? "▾" : "▸"} ${progress.done} done` });
290
291
  const more = count && done.length > count;
291
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.5",
3
+ "version": "1.0.7",
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",