@aeondave/pi-persona 1.17.1 → 1.17.3

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.
Files changed (103) hide show
  1. package/AGENTS.md +59 -28
  2. package/CHANGELOG.md +234 -0
  3. package/README.md +16 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +183 -55
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +82 -32
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +15 -10
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +165 -146
  48. package/src/engine/child.ts +2 -2
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +12 -7
  51. package/src/engine/stream.ts +4 -20
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/engine.ts +43 -16
  54. package/src/extension/hooks.ts +106 -36
  55. package/src/extension/persona-model-settings.ts +31 -13
  56. package/src/extension/shared.ts +15 -7
  57. package/src/extension.ts +505 -241
  58. package/src/monitor/archive.ts +235 -0
  59. package/src/monitor/session.ts +21 -1
  60. package/src/orchestration/flow-journal.ts +26 -5
  61. package/src/orchestration/flow-run.ts +38 -13
  62. package/src/orchestration/flow.ts +9 -2
  63. package/src/orchestration/reducers.ts +9 -5
  64. package/src/orchestration/render.ts +1 -1
  65. package/src/orchestration/sdk.ts +17 -5
  66. package/src/orchestration/strategies/compete.ts +5 -1
  67. package/src/orchestration/strategies/council-rounds.ts +2 -0
  68. package/src/orchestration/strategies/critic-loop.ts +3 -0
  69. package/src/orchestration/strategies/debate.ts +2 -0
  70. package/src/orchestration/strategies/fanout.ts +3 -2
  71. package/src/orchestration/strategies/judge.ts +5 -1
  72. package/src/orchestration/strategies/magi.ts +40 -23
  73. package/src/orchestration/strategies/map.ts +13 -3
  74. package/src/orchestration/strategies/pair.ts +2 -0
  75. package/src/orchestration/strategies/pipeline.ts +2 -0
  76. package/src/orchestration/strategies/synthesize.ts +27 -7
  77. package/src/orchestration/strategy.ts +31 -0
  78. package/src/persona/persona.ts +7 -3
  79. package/src/telemetry/contract.ts +1 -1
  80. package/src/tools/background-run.ts +151 -0
  81. package/src/tools/council.ts +77 -52
  82. package/src/tools/delegate-tool.ts +310 -280
  83. package/src/tools/delegate.ts +22 -210
  84. package/src/tools/exocom-work.ts +18 -53
  85. package/src/tools/exocom.ts +15 -19
  86. package/src/tools/flow.ts +85 -28
  87. package/src/tools/intercom-tool.ts +108 -118
  88. package/src/tools/intercom.ts +1 -1
  89. package/src/tools/models.ts +19 -9
  90. package/src/tools/monitor.ts +39 -6
  91. package/src/tools/timer.ts +11 -13
  92. package/src/ui/agent-overlay.ts +55 -14
  93. package/src/ui/agent-tree.ts +84 -18
  94. package/src/ui/compact-widget.ts +25 -0
  95. package/src/ui/failure-summary.ts +55 -0
  96. package/src/ui/literal-tool-result.ts +24 -0
  97. package/src/ui/model-picker.ts +158 -81
  98. package/src/ui/persona-settings.ts +9 -3
  99. package/src/ui/presentation.ts +24 -0
  100. package/src/ui/report-body.ts +63 -0
  101. package/src/ui/session-cards.ts +189 -0
  102. package/src/ui/wake-rows.ts +262 -0
  103. package/teams.yaml +5 -1
package/src/extension.ts CHANGED
@@ -11,12 +11,13 @@
11
11
  * isolation; see docs/ARCHITECTURE.md).
12
12
  */
13
13
 
14
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
+ import { randomUUID } from "node:crypto";
17
18
 
18
19
  import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
19
- import { Text } from "@earendil-works/pi-tui";
20
+ import { registerSessionCards } from "./ui/session-cards.ts";
20
21
 
21
22
  import type { AgentConfig } from "./agents/agent.ts";
22
23
  import { installBridge, isBridgeMode } from "./bridge.ts";
@@ -35,11 +36,14 @@ import {
35
36
  type SpineLegacyResult,
36
37
  type SpineLegacySelection,
37
38
  } from "./core/seed.ts";
38
- import { buildDelegationBrief } from "./core/brief.ts";
39
+ import { type BriefFlow, buildDelegationBrief } from "./core/brief.ts";
39
40
  import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
40
41
  import { fenceUntrusted } from "./core/fence.ts";
41
42
  import { sanitizeDisplayLabel } from "./core/display-label.ts";
42
- import { DelegationNudge, PersistenceNudge } from "./core/nudge.ts";
43
+ import { DelegationNudge, orderByMention, PersistenceNudge, PollGuard, renderNudgeModels, renderNudgeRoster } from "./core/nudge.ts";
44
+ import { orderModelCandidates, recommendModels, shortlistModels } from "./core/model-catalog.ts";
45
+ import { summarizeFailure } from "./ui/failure-summary.ts";
46
+ import { modelCandidateDetails, modelCostTiers, type ModelCostMetadata } from "./core/model-tier.ts";
43
47
  import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-compat.ts";
44
48
  import { type EngineAdapterBroker } from "./engine/adapter.ts";
45
49
  import { SupervisorBroker } from "./extension/broker-host.ts";
@@ -53,7 +57,6 @@ import {
53
57
  type PendingAsk,
54
58
  } from "./extension/shared.ts";
55
59
  export {
56
- agentNodeStatusForDelegate,
57
60
  boundExocomInboundBatch,
58
61
  canDeliverPersonaNotification,
59
62
  canonicalExocomTelemetryTargets,
@@ -84,22 +87,25 @@ import { collectCompanionSettings } from "./extension/companion-settings.ts";
84
87
  import { parseExocomArgv } from "./exocom/activation.ts";
85
88
  import { formatLedgerStatus } from "./exocom/status.ts";
86
89
  import { registerDelegateTool } from "./tools/delegate-tool.ts";
87
- import { registerIntercomTool } from "./tools/intercom-tool.ts";
90
+ import { describeRuns, registerIntercomTool } from "./tools/intercom-tool.ts";
88
91
  import { registerTimerTool } from "./tools/timer.ts";
89
- import { registerMonitorTool } from "./tools/monitor.ts";
92
+ import { registerMonitorTool, renderMonitorLog } from "./tools/monitor.ts";
93
+ import { MonitorArchive } from "./monitor/archive.ts";
90
94
  import { createMonitorSession, type MonitorSession } from "./monitor/session.ts";
95
+ import { createBackgroundRuns, type TrackedRun } from "./tools/background-run.ts";
91
96
  import { registerCouncilTool } from "./tools/council.ts";
92
97
  import { registerFlowTool } from "./tools/flow.ts";
93
98
  import { registerModelsTool } from "./tools/models.ts";
94
- import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, isAsyncRunStalled, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
99
+ import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekDigest, buildRetentionOverflowNote, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
95
100
  import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
96
101
  import { brokerEndpoint } from "./bus/broker/paths.ts";
97
102
  import { InProcessBus } from "./bus/inproc.ts";
98
103
  import { loadContracts, loadDefinitions, loadPresets, loadTeams, type LoadResult, type ScopedDir } from "./loader.ts";
99
- import { type FlowSpec, flowHash, parseFlow, verifyFlowRefs } from "./orchestration/flow.ts";
100
- import { journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
104
+ import { type FlowSpec, flowRunHash, parseFlow, verifyFlowRefs } from "./orchestration/flow.ts";
105
+ import { clearJournal, journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
101
106
  import { runFlow } from "./orchestration/flow-run.ts";
102
- import type { FlowOutcome } from "./orchestration/flow-run.ts";
107
+ import { Semaphore } from "./orchestration/parallel.ts";
108
+ import { flowDisposition, type FlowOutcome } from "./orchestration/flow-run.ts";
103
109
  import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
104
110
  import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
105
111
  import { knownParams, strategyNames } from "./orchestration/strategy.ts";
@@ -126,13 +132,15 @@ import { shortModel } from "./tools/delegate.ts";
126
132
  import { formatInbox } from "./tools/intercom.ts";
127
133
  import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
128
134
  import { AgentOverlay } from "./ui/agent-overlay.ts";
129
- import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
135
+ import { type AddNodeInput, type AgentNode, type AgentNodePatch, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, isWakeSource, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
136
+ import { WakeRows, type WakeRowChange } from "./ui/wake-rows.ts";
130
137
  import { LiveClock } from "./ui/live-clock.ts";
131
- import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
132
- import { compactInlineText, compactVisibleText, sanitizeTerminalText } from "./ui/presentation.ts";
138
+ import { filterModels, ModelPicker, orderModelRefs, type ModelPickerMetadata } from "./ui/model-picker.ts";
139
+ import { compactInlineText, gateReviewText } from "./ui/presentation.ts";
140
+ import { compactWidget } from "./ui/compact-widget.ts";
133
141
  import { ChildUsageLedger, formatPersonaCostStatus, formatUsage, PERSONA_COST_STATUS_KEY } from "./ui/usage.ts";
134
142
 
135
- const RUN_LIMITS: RunLimits = {
143
+ export const RUN_LIMITS: RunLimits = {
136
144
  // A generous anti-runaway backstop, not a council-size cap: a declared ensemble
137
145
  // (e.g. 9 members × several rounds) must fit; the token budget is the real cost guard.
138
146
  maxChildren: 64,
@@ -175,6 +183,8 @@ interface CommandResultEntry {
175
183
  label: string;
176
184
  content: string;
177
185
  ok: boolean;
186
+ /** A flow that paused at a rejected checkpoint: not a failure, so it is titled "stopped". */
187
+ stoppedAtGate?: string;
178
188
  failureKind?: FailureKind;
179
189
  error?: string;
180
190
  }
@@ -188,8 +198,17 @@ interface CheckInEntry {
188
198
  kind?: "routine" | "stall";
189
199
  }
190
200
 
201
+ interface RunFailureEntry {
202
+ id: string;
203
+ label: string;
204
+ agent: string;
205
+ model?: string;
206
+ durationMs?: number;
207
+ error: string;
208
+ }
209
+
191
210
  /**
192
- * Keep the immediate operator-facing settlement toast and the semantic completion delivery on one
211
+ * Keep the immediate operator-facing settlement feedback and the semantic completion delivery on one
193
212
  * path. A supervisor-requested abort is terminal, but it is not an execution error: the tracker
194
213
  * classifies it as `stopped`, so the UI reports it at info level while the completion still reaches
195
214
  * the supervisor exactly once through the normal notifier.
@@ -198,31 +217,51 @@ export function announceAsyncRunSettlement(
198
217
  run: AsyncRun,
199
218
  notify: ((message: string, level: "info" | "error") => void) | undefined,
200
219
  enqueue: (run: AsyncRun) => void,
220
+ presentFailure?: (run: AsyncRun) => void,
201
221
  ): void {
202
222
  const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
203
223
  const agent = compactInlineText(run.label ?? run.agent, { maxChars: 80 }) || compactInlineText(run.agent, { maxChars: 80 }) || "agent";
204
224
  try {
205
225
  if (run.status === "done") notify?.(`async run ${id} (${agent}) completed`, "info");
206
226
  else if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
207
- else if (run.status === "failed") notify?.(`async run ${id} (${agent}) failed: ${compactInlineText(run.error ?? "(no detail)", { maxChars: 240 })}`, "error");
227
+ else if (run.status === "failed") {
228
+ const toast = () => notify?.(`async run ${id} (${agent}) failed: ${summarizeFailure(run.error ?? "(no detail)")}`, "error");
229
+ if (presentFailure) {
230
+ try { presentFailure(run); }
231
+ catch { toast(); }
232
+ } else toast();
233
+ }
208
234
  } catch {
209
235
  // Cosmetic notification only: a broken UI must never swallow the terminal report.
210
236
  }
211
237
  enqueue(run);
212
238
  }
213
239
 
240
+ /** An operator-run `/flow` or `/orchestrate` result as the supervisor's NEXT turn sees it: one header
241
+ * line, then the output fenced as data (it is sub-agent text) and bounded like a completion. */
242
+ export function renderOperatorResult(label: string, output: string, ok: boolean, stoppedAtGate?: string): string {
243
+ const disposition = flowDisposition({ ok, stoppedAtGate });
244
+ const state = disposition === "done" ? "finished" : disposition === "stopped" ? `stopped at checkpoint "${compactInlineText(stoppedAtGate ?? "", { maxChars: 64 })}"` : "failed";
245
+ return boundCompletionSurface(
246
+ `[pi-persona] The operator ran ${compactInlineText(label, { maxChars: 96 })} (${state}); its output:\n${fenceUntrusted(output)}`,
247
+ "operator result truncated at 12000 characters; only the operator can see the rest (ask them to paste what you need)",
248
+ );
249
+ }
250
+
214
251
  /** Agents actually IN FLIGHT: a RUNNING node with no children of its own. "Has a parent" is not the
215
252
  * same as "is an agent" — a flow phase is a container that also has a parent, a root-level `async:`
216
253
  * run is a leaf that has none, and a settled leg still sits in the tree until its root is torn
217
- * down. Exported for direct unit testing (mirrors `listPeersForGroup` above). */
218
- export function inFlightAgentCount(nodes: ReadonlyArray<{ id: string; parentId?: string | undefined; status: AgentNodeStatus }>): number {
254
+ * down. A fan-out's `delegate` header row is a container even in the beat after its last leg
255
+ * left, so it never counts. A wake source (a timer or monitor row) is not an agent at all and
256
+ * never counts, running or not. Exported for direct unit testing (mirrors `listPeersForGroup` above). */
257
+ export function inFlightAgentCount(nodes: ReadonlyArray<{ id: string; parentId?: string | undefined; status: AgentNodeStatus; kind?: NonNullable<AgentNode["kind"]> }>): number {
219
258
  const parents = new Set<string>();
220
259
  for (const node of nodes) {
221
260
  if (node.parentId !== undefined) parents.add(node.parentId);
222
261
  }
223
262
  let count = 0;
224
263
  for (const node of nodes) {
225
- if (node.status === "running" && !parents.has(node.id)) count++;
264
+ if (node.status === "running" && node.kind !== "delegate" && !isWakeSource(node) && !parents.has(node.id)) count++;
226
265
  }
227
266
  return count;
228
267
  }
@@ -285,75 +324,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
285
324
  let config = resolveConfig(process.env);
286
325
  if (config.disabled) return;
287
326
  const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
288
- const registerFollowUpRenderer = (customType: "pi-persona" | "exocom_received", label: string): void => {
289
- pi.registerMessageRenderer(customType, (message, { expanded, outputPad }, theme) => {
290
- const raw = typeof message.content === "string"
291
- ? message.content
292
- : message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
293
- const full = sanitizeTerminalText(raw);
294
- const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
295
- const firstLine = full.split("\n", 1)[0] ?? "";
296
- // External peer text is data and must never choose our UI chrome. Internal follow-ups own
297
- // their first status line; parse only that line and do not turn a healthy "0 failed" red.
298
- const isFailure = customType === "pi-persona"
299
- && !/\b0 failed\b/i.test(firstLine)
300
- && /\b(?:failed|failure|error|rejected|unresolved)\b/i.test(firstLine);
301
- const titleColor = isFailure ? "error" : customType === "exocom_received" ? "accent" : "success";
302
- const title = theme.fg(titleColor, theme.bold(label));
303
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full || "(no output)")}`, outputPad, 0);
304
- const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
305
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, outputPad, 0);
306
- });
307
- };
308
- // These follow-ups remain complete in the model context, but the human-facing card starts as a
309
- // bounded digest. This is the central progressive-disclosure boundary for async completions and
310
- // external peer messages; expanding the card reveals the exact sanitized payload.
311
- registerFollowUpRenderer("pi-persona", "pi-persona · update");
312
- registerFollowUpRenderer("exocom_received", "exocom · inbound");
313
- pi.registerEntryRenderer("pi-persona-result", (entry, { expanded }, theme) => {
314
- const data = entry.data as CommandResultEntry;
315
- const full = sanitizeTerminalText(data.content || "(no output)");
316
- const cause = compactVisibleText(
317
- [data.failureKind, data.error].filter((part): part is string => !!part).join(" · "),
318
- { maxLines: 1, maxLineChars: 80 },
319
- ).text;
320
- const status = data.ok ? "complete" : "failed";
321
- const label = compactInlineText(data.label, { maxChars: 96 }) || "result";
322
- const title = theme.fg(data.ok ? "success" : "error", theme.bold(`${label} · ${status}${!data.ok && cause !== "(no output)" ? ` · ${cause}` : ""}`));
323
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
324
- const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
325
- const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
326
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
327
- });
328
- pi.registerEntryRenderer("pi-persona-nudge", (entry, { expanded }, theme) => {
329
- const data = entry.data as NudgeEntry;
330
- const full = sanitizeTerminalText(data.content || "⟢ pi-persona · checkpoint");
331
- const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
332
- const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
333
- const details = lines.slice(1);
334
- const title = theme.fg("warning", theme.bold(`⟢ ${header}`));
335
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", details.join("\n") || "(no detail)")}`, 0, 0);
336
- const trigger = details.find((line) => line.startsWith("Trigger:"));
337
- const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
338
- const selected = [trigger, action].filter((line): line is string => line !== undefined);
339
- const previewLines = selected.length > 0 ? selected : details.slice(0, 2);
340
- const preview = compactVisibleText(previewLines.join("\n"), { maxLines: 2, maxLineChars: 100 });
341
- const hasMore = preview.truncated || details.some((line) => !previewLines.includes(line));
342
- const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
343
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
344
- });
345
- pi.registerEntryRenderer("pi-persona-status", (entry, { expanded }, theme) => {
346
- const data = entry.data as CheckInEntry;
347
- const full = sanitizeTerminalText(data.content || "Async activity · routine check-in");
348
- const title = theme.fg(data.kind === "stall" ? "warning" : "accent", theme.bold(data.kind === "stall" ? "pi-persona · possible stall" : "pi-persona · async status"));
349
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
350
- // The entry title already labels this as async status. Spend the compact preview on the
351
- // aggregate and first run row so the operator sees the selected alias and usage label.
352
- const digestLines = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
353
- const preview = compactVisibleText(digestLines.slice(0, 2).join("\n") || full, { maxLines: 2, maxLineChars: 140 });
354
- const hasMore = preview.truncated || digestLines.length > 2;
355
- const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
356
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
327
+ registerSessionCards(pi, {
328
+ detailHint: expandDetailHint,
329
+ monitorLog: (record) => renderMonitorLog(record, true),
357
330
  });
358
331
  const appendNudgeEntry = (content: string): void => {
359
332
  pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
@@ -365,16 +338,35 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
365
338
  label: string,
366
339
  content: string,
367
340
  ok: boolean,
368
- meta: { failureKind?: FailureKind; error?: string } = {},
341
+ meta: { failureKind?: FailureKind; error?: string; stoppedAtGate?: string } = {},
369
342
  ): void => {
370
343
  pi.appendEntry("pi-persona-result", {
371
344
  label,
372
345
  content,
373
346
  ok,
347
+ ...(meta.stoppedAtGate !== undefined ? { stoppedAtGate: meta.stoppedAtGate } : {}),
374
348
  ...(meta.failureKind ? { failureKind: meta.failureKind } : {}),
375
349
  ...(meta.error ? { error: meta.error } : {}),
376
350
  } satisfies CommandResultEntry);
377
351
  };
352
+ /** An operator-run `/flow` or `/orchestrate` result: the card for the operator, and the same text for the
353
+ * supervisor's NEXT turn — `nextTurn` is passive (it never starts one), so "now implement the plan" or
354
+ * "apply the winning diff" finds the result in context instead of nothing. */
355
+ const appendOperatorResult: typeof appendCommandResult = (label, content, ok, meta = {}) => {
356
+ appendCommandResult(label, content, ok, meta);
357
+ try {
358
+ pi.sendMessage({ customType: "pi-persona", content: renderOperatorResult(label, content, ok, meta.stoppedAtGate), display: false }, { deliverAs: "nextTurn" });
359
+ } catch {
360
+ /* the card above is the operator's copy; a host that cannot queue the hand-off must not fail the command */
361
+ }
362
+ };
363
+ /** `/flow` and `/orchestrate` result card; `ok` stays honest, a rejected checkpoint is "stopped". */
364
+ const appendFlowResult = (flow: string, outcome: FlowOutcome): void =>
365
+ appendOperatorResult(`flow ${flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
366
+ ...(outcome.stoppedAtGate !== undefined ? { stoppedAtGate: outcome.stoppedAtGate } : {}),
367
+ ...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
368
+ ...(outcome.error ? { error: outcome.error } : {}),
369
+ });
378
370
  const enabledSpineRoles = (): SpineLegacySelection => ({
379
371
  supervisor: config.spine === "on",
380
372
  worker: config.spineLegs === "on",
@@ -572,6 +564,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
572
564
  // The unified live tree of every in-flight agent — strategy cores, delegate
573
565
  // sub-agents, dynamic specialists — rendered as one sticky widget above the input.
574
566
  const agentTree = new AgentTree();
567
+ const wakeRows = new WakeRows();
575
568
  // Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
576
569
  // session-scoped and starts only after persona + exocom reconciliation in session_start.
577
570
  let telemetry: TelemetryProducer | undefined;
@@ -594,7 +587,6 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
594
587
  if (node.kind) return node.kind;
595
588
  if (node.id.startsWith("flow:")) return "flow";
596
589
  if (node.id.startsWith("strategy:") || node.id.startsWith("council:")) return "council";
597
- if (node.id.startsWith("delegate:")) return "delegate";
598
590
  return "subagent";
599
591
  }
600
592
  function telemetryStatus(node: AgentNode): TelemetryAgentStatus {
@@ -660,6 +652,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
660
652
  function publishAgentTreeChange(change: AgentTreeChange): void {
661
653
  const producer = telemetry;
662
654
  if (!producer) return;
655
+ // Wake rows (timers, monitors) are not agents, and their labels are free text the telemetry
656
+ // contract never persists: they stay on the local widget and never reach the bus.
657
+ if ((change.type === "added" || change.type === "updated") && isWakeSource(change.node)) return;
663
658
  if (change.type === "added") {
664
659
  lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
665
660
  producer.publishAgentAdded(telemetryAgent(change.node));
@@ -670,7 +665,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
670
665
  return;
671
666
  }
672
667
  if (change.type === "removed") {
673
- for (const node of change.nodes) {
668
+ for (const node of change.nodes.filter((n) => !isWakeSource(n))) {
674
669
  lastAgentProjection.delete(node.id);
675
670
  const status = node.status === "running" ? "stopped" : telemetryStatus(node);
676
671
  producer.publish("agent.removed", { id: node.id, status });
@@ -683,10 +678,17 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
683
678
  agentTree.onChange(publishAgentTreeChange);
684
679
  // Delegation nudge: watches the supervisor's OWN tool-result stream and reminds it to hand off
685
680
  // when it grinds heavy work by hand (config.nudge; gated to delegating personas at the hook).
686
- const delegationNudge = new DelegationNudge();
681
+ // Its reminder cites the live delegate targets and the session provider's models (read when it
682
+ // fires: agents change on /persona reload, the session model on a switch).
683
+ const delegationNudge = new DelegationNudge(undefined, {
684
+ roster: () => renderNudgeRoster(orderByMention(delegateTargets(), controller.activePersona?.body)),
685
+ models: () => (canDiscoverModels() && lastCtx?.model ? currentModelCatalog(lastCtx).candidates : undefined),
686
+ });
687
687
  // Persistence nudge: the counterweight — when a delegated leg comes back BLOCKED/UNKNOWN, remind
688
688
  // the supervisor not to bank a premature surrender (same config.nudge gate + hook as above).
689
689
  const persistenceNudge = new PersistenceNudge();
690
+ // Poll guard: refuses an unchanged repeat of intercom peek/result (same kill switch).
691
+ const pollGuard = config.nudge ? new PollGuard() : undefined;
690
692
  // Every visible run (strategy, council, flow) gets its own tree root — see makeRootIdAllocator.
691
693
  const nextRootId = makeRootIdAllocator();
692
694
  // node id → abort that one agent (so the overlay can STOP a single sub-agent).
@@ -708,7 +710,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
708
710
  fn();
709
711
  agentTree.update(nodeId, { detail: "stopping…" }); // visible at once; the run's own settle path writes the terminal status
710
712
  flushAgentWidgetRender();
711
- stopRequested.add(nodeId);
713
+ // A wake source settles synchronously inside `fn()` and clears its own handle, so there is no
714
+ // pending stop to remember (tracker runs keep their handle until they settle, so they are unchanged).
715
+ if (stopRegistry.has(nodeId)) stopRequested.add(nodeId);
712
716
  steerRegistry.delete(nodeId); // a hard-stopped agent is no longer steerable (mirror the strategy path)
713
717
  return true;
714
718
  }
@@ -717,7 +721,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
717
721
  return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
718
722
  }
719
723
  function waitingAsyncRunIds(): Set<string> {
720
- return new Set(tracker.running().filter((run) => steerRegistry.get(`async:${run.id}`)?.isWaitingForSupervisor?.() === true).map((run) => run.id));
724
+ // Through the tree, so a council/flow run (a root whose LEGS hold the steer handles) reads as waiting
725
+ // exactly when every live leg does, the same rule a delegate leg answers directly.
726
+ const waiting = waitingTreeIds();
727
+ return new Set(tracker.running().filter((run) => waiting.has(`async:${run.id}`)).map((run) => run.id));
721
728
  }
722
729
  const clearSteers = (prefix: string): void => {
723
730
  for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
@@ -740,6 +747,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
740
747
  const WIDGET_RENDER_DELAY_MS = 16;
741
748
  let widgetTimer: ReturnType<typeof setTimeout> | undefined;
742
749
  let widgetRenderPending = false;
750
+ let publishingMonitorOutput = false;
743
751
  let widgetLinesInitialized = false;
744
752
  let lastWidgetLines: readonly string[] | undefined;
745
753
  let widgetStatusInitialized = false;
@@ -750,14 +758,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
750
758
  if (!a || !b || a.length !== b.length) return false;
751
759
  return a.every((line, i) => line === b[i]);
752
760
  }
753
- function renderAgentWidget(nodes: readonly AgentNode[] = agentTree.snapshot()): void {
761
+ /** The "N agents" status answers how many agents are working, so a tree holding only wake rows
762
+ * (timers, monitors) publishes none, while the widget lines still show them. */
763
+ function agentCountStatus(nodes: readonly AgentNode[]): string | undefined {
764
+ const agents = nodes.filter((node) => !isWakeSource(node));
765
+ return agents.length === 0 ? undefined : String(inFlightAgentCount(agents));
766
+ }
767
+ function renderAgentWidget(): void {
754
768
  if (!lastCtx) return;
769
+ // Publish dirty monitor tails into the tree BEFORE this frame takes its snapshot. The tree's
770
+ // overlay listeners still see the patches, but our own listener must not request a second frame.
771
+ if (!disposed) {
772
+ publishingMonitorOutput = true;
773
+ try {
774
+ for (const change of wakeRows.flushMonitorOutput()) agentTree.update(change.id, change.patch);
775
+ } finally { publishingMonitorOutput = false; }
776
+ }
777
+ const nodes = agentTree.snapshot();
755
778
  const empty = nodes.length === 0;
756
779
  const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
757
- const status = empty ? undefined : String(inFlightAgentCount(nodes));
780
+ const status = agentCountStatus(nodes);
758
781
  try {
759
782
  if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
760
- lastCtx.ui.setWidget("persona-agents", lines, { placement: "aboveEditor" });
783
+ lastCtx.ui.setWidget("persona-agents", lines ? compactWidget(lines) : undefined, { placement: "aboveEditor" });
761
784
  lastWidgetLines = lines;
762
785
  widgetLinesInitialized = true;
763
786
  }
@@ -776,8 +799,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
776
799
  }
777
800
  function renderAgentStatus(): void {
778
801
  if (!lastCtx) return;
779
- const nodes = agentTree.snapshot();
780
- const status = nodes.length === 0 ? undefined : String(inFlightAgentCount(nodes));
802
+ const status = agentCountStatus(agentTree.snapshot());
781
803
  try {
782
804
  if (!widgetStatusInitialized || lastWidgetStatus !== status) {
783
805
  lastCtx.ui.setStatus("persona-agents", status);
@@ -839,6 +861,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
839
861
  // elapsed time and the stall badge moving without a progress event, and stops itself when idle.
840
862
  const widgetClock = new LiveClock({ intervalMs: 1_000, isLive: () => agentTree.hasRunning(), onTick: renderAgentWidget });
841
863
  agentTree.onChange((change) => {
864
+ if (publishingMonitorOutput) return;
842
865
  const refreshStatus = widgetStatusMayHaveChanged(change);
843
866
  if (disposed) {
844
867
  // session_shutdown clears the tree after marking this activation disposed. Keep teardown
@@ -966,6 +989,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
966
989
  definitionWarnings = [...result.warnings, ...contractsLoad.warnings];
967
990
  }
968
991
 
992
+ // Empty by design on a fresh install (see above): point at the install gesture from every
993
+ // surface that would otherwise be silent or read as broken — `/persona list`, /doctor, F8.
994
+ const NO_PERSONAS_HINT =
995
+ "No personas installed. pi-persona does not auto-install — run `/persona seed` to copy the bundled defaults into your agent dir (edit them freely; `/persona restore` re-installs the originals).";
996
+
969
997
  // Install the bundled defaults into the user's agent dir — this is the ONLY way personas/agents
970
998
  // become active (they are not a live builtin layer). `/persona seed` pulls in missing defaults,
971
999
  // `/persona restore` force-overwrites them back to the originals. First-run auto-install is
@@ -1117,12 +1145,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1117
1145
  };
1118
1146
  // Async runs outlive the turn that launched them; on completion we surface the result(s) back as
1119
1147
  // ONE coalesced notice so the (idle) supervisor can react — e.g. retry a failure with another model.
1120
- // With delegate now background-by-default (interactive), this is the COMMON delivery path, so the
1121
- // premature-surrender counterweight must ride it too: a settled leg whose report carries a
1122
- // [BLOCKED]/FLAG:UNKNOWN marker gets the persistence note appended (the sync tool_result hook can't
1123
- // see a background run — its report arrives here as a fresh follow-up, not a delegate result).
1148
+ // Interactive delegate ALWAYS runs in the background, so this is the COMMON delivery path: the
1149
+ // premature-surrender counterweight rides it (the sync tool_result hook can't see a background run),
1150
+ // and a `tasks` fan-out is held until its LAST leg settles so the supervisor is woken once with every
1151
+ // result instead of once per leg. Single legs are never held.
1124
1152
  const completionNotifier = new IdleCoalescingNotifier<AsyncRun>({
1125
1153
  ...idleDelivery,
1154
+ isReady: (run) => tracker.batchSettled(run),
1126
1155
  render: (runs) => {
1127
1156
  const report = renderCompletion(runs, fenceUntrusted, (t) => scanForSurrender(t));
1128
1157
  // Retention is a bound, not a promise: a fan-out wider than it settles more payloads than the
@@ -1155,6 +1184,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1155
1184
  ...idleDelivery,
1156
1185
  render: (entries) => renderTimerFire(entries),
1157
1186
  });
1187
+ // Wake sources (timers, monitors) are rows beside the sub-agents: added when armed, updated on
1188
+ // events, settled like an async run. The final update and the removal are synchronous and the
1189
+ // widget composition is coalesced, so the final status reaches the tree change stream, never the
1190
+ // screen; the row simply leaves, together with its stop handle.
1191
+ function settleWakeRow(change: WakeRowChange): void {
1192
+ // session_shutdown clears the tree and both registries itself right after cancelAll, so
1193
+ // settling rows one by one would only repaint a widget it is about to clear (the tree
1194
+ // listener repaints synchronously once disposed). The leftover timer:/monitor: stop handles
1195
+ // are harmless: cancelling an unknown id is a no-op.
1196
+ if (disposed) return;
1197
+ agentTree.update(change.id, change.patch);
1198
+ agentTree.remove(change.id);
1199
+ stopRegistry.delete(change.id);
1200
+ stopRequested.delete(change.id);
1201
+ }
1158
1202
  const timerScheduler = new TimerScheduler({
1159
1203
  now: () => Date.now(),
1160
1204
  setTimer: (fn, ms) => {
@@ -1163,7 +1207,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1163
1207
  return h;
1164
1208
  },
1165
1209
  clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
1166
- onFire: (entry) => timerNotifier.notify(entry),
1210
+ onArmed: (entry) => {
1211
+ const row = wakeRows.timerArmed(entry);
1212
+ stopRegistry.set(row.id, () => { timerScheduler.cancel(entry.id); });
1213
+ agentTree.add(row);
1214
+ },
1215
+ onCancelled: (entry) => settleWakeRow(wakeRows.timerCancelled(entry)),
1216
+ onFire: (entry) => { settleWakeRow(wakeRows.timerFired(entry)); timerNotifier.notify(entry); },
1167
1217
  });
1168
1218
  const canRunMonitor = () => {
1169
1219
  if (disposed) return false;
@@ -1172,26 +1222,66 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1172
1222
  return active.includes("monitor") && active.includes("bash")
1173
1223
  && (!caps || (canCallTool(caps, "monitor") && canCallTool(caps, "bash")));
1174
1224
  };
1225
+ // Session-native custom entries own the bounded logs: they are not model messages and
1226
+ // deleting the session removes them without orphan sidecars. Shutdown persists, never deletes.
1227
+ const monitorArchive = new MonitorArchive({
1228
+ entries: () => lastCtx?.sessionManager?.getEntries?.() ?? [],
1229
+ append: (customType, data) => pi.appendEntry(customType, data),
1230
+ });
1175
1231
  monitors = createMonitorSession({
1232
+ idPrefix: `monitor-${randomUUID().slice(0, 8)}`,
1176
1233
  isIdle: idleDelivery.isIdle,
1177
1234
  deliver: (text) => sendPersonaFollowUp(pi, text),
1178
1235
  canRun: canRunMonitor,
1236
+ observe: {
1237
+ armed: (entry) => {
1238
+ monitorArchive.armed(entry);
1239
+ if (disposed) return;
1240
+ const row = wakeRows.monitorArmed(entry);
1241
+ stopRegistry.set(row.id, () => { monitors?.cancel(entry.id); });
1242
+ agentTree.add(row);
1243
+ },
1244
+ output: (output) => {
1245
+ monitorArchive.output(output);
1246
+ if (disposed) return;
1247
+ if (wakeRows.monitorOutput(output)) requestAgentWidgetRender(false);
1248
+ },
1249
+ event: (event) => {
1250
+ monitorArchive.event(event);
1251
+ if (disposed) return;
1252
+ const change = wakeRows.monitorEvent(event);
1253
+ if (change.settled) settleWakeRow(change);
1254
+ else agentTree.update(change.id, change.patch);
1255
+ },
1256
+ removed: (entry) => {
1257
+ monitorArchive.removed(entry);
1258
+ if (disposed) return;
1259
+ settleWakeRow(wakeRows.monitorRemoved(entry));
1260
+ },
1261
+ },
1179
1262
  });
1180
1263
  const tracker = new AsyncRunTracker({ maxRetained: config.asyncRetain });
1181
1264
  // The premature-surrender counterweight rides the SAME kill switch as the by-hand nudge
1182
- // (PI_PERSONA_NUDGE=off). Both completion surfaces — the background follow-up and `intercom wait` —
1183
- // go through here, so "off" means off wherever a settled leg is collected, not just on the
1184
- // synchronous tool_result hook.
1265
+ // (PI_PERSONA_NUDGE=off). The background completion follow-up goes through here, so "off" means
1266
+ // off there too, not just on the synchronous tool_result hook.
1185
1267
  const scanForSurrender = (text: string): string | undefined => (config.nudge ? persistenceNudge.scan(text) : undefined);
1186
1268
  // A run id that is neither live nor retained has two very different meanings: it never existed, or
1187
1269
  // its payload was evicted under retention pressure. Only the second deserves an explanation — and a
1188
1270
  // bare "no such run" for a result the supervisor was just TOLD to fetch is exactly the dead end
1189
1271
  // this distinction exists to prevent.
1190
1272
  function missingRunMessage(id: string, display: string | undefined): string {
1191
- if (!tracker.wasDropped(id)) return `No retained async run "${display}".`;
1273
+ if (tracker.wasDropped(id)) {
1274
+ return (
1275
+ `Async run "${display}" settled, but its full payload is gone: more runs settled than the ${tracker.retention}-run retention bound keeps. ` +
1276
+ "Its summary is, or will be, in the completion follow-up. Raise PI_PERSONA_ASYNC_RETAIN, or fan out in narrower waves and collect as you go, to keep more."
1277
+ );
1278
+ }
1279
+ // Never existed, or ended with the session that launched it (a call-sign is not an address either):
1280
+ // list what does exist — running first, then the newest settled — so the next call can be right.
1281
+ const settled = tracker.list().filter((run) => run.status !== "running").reverse().sort((a, b) => (b.settledAt ?? 0) - (a.settledAt ?? 0));
1192
1282
  return (
1193
- `Async run "${display}" settled, but its full payload is gone: more runs settled than the ${tracker.retention}-run retention bound keeps. ` +
1194
- "Its summary was delivered in the completion follow-up. Raise PI_PERSONA_ASYNC_RETAIN, or fan out in narrower waves and collect as you go, to keep more."
1283
+ `No retained async run "${display}". Runs live only in the session that launched them (a restart, /new or reload ends them; collected results are evicted first once retention is full). ` +
1284
+ `Known runs: ${describeRuns([...tracker.running(), ...settled], { status: true })}.`
1195
1285
  );
1196
1286
  }
1197
1287
  // Turns the periodic peek from a poll into an exception signal: it surfaces a leg only when it
@@ -1205,11 +1295,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1205
1295
  stopRequested.delete(`async:${run.id}`);
1206
1296
  if (disposed) return; // instance torn down — don't notify the next session or re-arm a cancelled timer
1207
1297
  // Immediate human feedback + one semantic completion. Explicit stops are informational,
1208
- // while natural failures retain the error toast and every terminal result is still delivered.
1298
+ // while failures get an expandable TUI card (a toast elsewhere). Every result is still delivered.
1209
1299
  announceAsyncRunSettlement(
1210
1300
  run,
1211
1301
  lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
1212
1302
  (settled) => completionNotifier.notify(settled),
1303
+ lastCtx?.hasUI && lastCtx.mode === "tui" ? (failed) => {
1304
+ const model = failed.result?.modelUsed ?? failed.model;
1305
+ const durationMs = failed.startedAt !== undefined && failed.settledAt !== undefined
1306
+ ? Math.max(0, failed.settledAt - failed.startedAt) : undefined;
1307
+ pi.appendEntry("pi-persona-run-failure", {
1308
+ id: failed.id, label: failed.label ?? failed.agent, agent: failed.agent,
1309
+ ...(model ? { model } : {}), ...(durationMs !== undefined ? { durationMs } : {}),
1310
+ error: failed.error ?? "(no detail)",
1311
+ } satisfies RunFailureEntry);
1312
+ } : undefined,
1213
1313
  );
1214
1314
  if (tracker.running().length === 0) stopPeek(); // no live runs → stop the idle peek
1215
1315
  });
@@ -1244,9 +1344,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1244
1344
  });
1245
1345
  });
1246
1346
 
1247
- // Async-run watchdog: the fast PI_PERSONA_PEEK_MS cadence wakes the supervisor only for a newly-stalled
1248
- // leg or unread message. The slower PI_PERSONA_CHECKIN_MS cadence appends a durable operator-only status
1249
- // card; it does not start a model turn. Bounded: unref'd, self-stops when no runs remain.
1347
+ // Async-run watchdog: the fast PI_PERSONA_PEEK_MS cadence appends an operator-only card for a
1348
+ // newly-stalled leg and wakes the supervisor only for an unread message. The slower
1349
+ // PI_PERSONA_CHECKIN_MS cadence appends a durable operator-only status card; it does not start a
1350
+ // model turn. Bounded: unref'd, self-stops when no runs remain.
1250
1351
  let peekTimer: ReturnType<typeof setInterval> | undefined;
1251
1352
  // Last time progress status was surfaced (a new stall alert or routine card), for the slow cadence.
1252
1353
  let lastPeekAt = 0;
@@ -1257,7 +1358,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1257
1358
  }
1258
1359
  }
1259
1360
  function startPeek(): void {
1260
- // Fast wakeups and routine cards opt out independently. The carrier timer runs while EITHER is on
1361
+ // Fast ticks and routine cards opt out independently. The carrier timer runs while EITHER is on
1261
1362
  // and ticks at the faster of the two (a disabled one is Infinity, so it never drives).
1262
1363
  const fastMs = config.peekEveryMs > 0 ? config.peekEveryMs : Number.POSITIVE_INFINITY;
1263
1364
  const checkMs = config.checkInEveryMs > 0 ? config.checkInEveryMs : Number.POSITIVE_INFINITY;
@@ -1296,23 +1397,14 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1296
1397
  lastPeekAt = now;
1297
1398
  appendStatus(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }), "routine");
1298
1399
  }
1299
- // Interactive stalls are operator-only. Headless sessions may consume one model wake per
1300
- // run lifetime; unread non-ask messages retain their existing actionable route.
1400
+ // A stall is operator-only (appended above) and never costs a model turn; unread non-ask
1401
+ // messages keep their actionable route.
1301
1402
  if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
1302
1403
  const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
1303
- const wakeCandidates = fast && lastCtx?.hasUI === false
1304
- ? peekWatcher.firstWakeCandidates(runs.filter((run) => isAsyncRunStalled(run, now, STALL_FLAG_MS)))
1305
- : [];
1306
- if (wakeCandidates.length === 0 && unread.length === 0) return;
1307
- const parts: string[] = [];
1308
- if (wakeCandidates.length > 0) parts.push(buildPeekAlert(wakeCandidates, { now }));
1309
- if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1404
+ if (unread.length === 0) return;
1310
1405
  try {
1311
- sendPersonaFollowUp(pi, `[pi-persona] ${parts.join("\n\n")}`);
1312
- peekWatcher.acknowledgeModelWake(wakeCandidates.map((run) => run.id));
1313
- } catch {
1314
- peekWatcher.retryReports(wakeCandidates);
1315
- }
1406
+ sendPersonaFollowUp(pi, `[pi-persona] 📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1407
+ } catch { /* best-effort wake: the session may be tearing down */ }
1316
1408
  }, tickMs);
1317
1409
  peekTimer.unref?.();
1318
1410
  }
@@ -1431,15 +1523,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1431
1523
  * Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
1432
1524
  * authenticated model to choose between.
1433
1525
  */
1434
- async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
1526
+ async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], signal: AbortSignal, rootId: string, tracked?: TrackedRun): Promise<void> {
1435
1527
  const scope = modelSelectionScope(ctx);
1436
- if (!scope || !ctx.hasUI) return;
1528
+ if (!scope || !ctx.hasUI || signal.aborted) return;
1437
1529
  const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
1438
1530
  if (missing.length === 0) return;
1439
1531
  const available = configuredModels(ctx);
1440
1532
  if (available.length < 2) return; // automatic setup cannot diversify a single model
1441
- const chosen = await choosePersonaModels(ctx, missing, available, scope);
1442
- if (chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
1533
+ // A tracked run parked at this picker is waiting on the OPERATOR: say so, or it reads "queued" and,
1534
+ // past the stall window, "possibly stuck".
1535
+ tracked?.onAwaiting("waiting for operator to pick models");
1536
+ agentTree.update(rootId, { detail: "waiting for operator to pick models" });
1537
+ try {
1538
+ const choose = async (): Promise<void> => {
1539
+ if (signal.aborted || !scope.isCurrent()) return;
1540
+ // A concurrent run may have assigned or dismissed these participants while this one waited.
1541
+ const assigned = personaModels(personaConfigs, scope.persona);
1542
+ const pending = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) })
1543
+ .filter((p) => !assigned[p.key] && !assigned[p.agent]);
1544
+ const chosen = await choosePersonaModels(ctx, pending, available, scope, signal);
1545
+ if (!signal.aborted && chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
1546
+ };
1547
+ // Every automatic setup shares the checkpoint dialog line, including commands and mandatory
1548
+ // orchestration. The run's stop closes the active picker or removes it from the line.
1549
+ await inGateLine(signal, undefined, choose);
1550
+ } finally {
1551
+ tracked?.onAwaiting(undefined);
1552
+ agentTree.update(rootId, { detail: "", lastAdvanceAt: Date.now() });
1553
+ }
1443
1554
  }
1444
1555
 
1445
1556
  type ModelSelectionScope = { persona: string; isCurrent(): boolean };
@@ -1476,15 +1587,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1476
1587
  }
1477
1588
 
1478
1589
  /** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
1479
- async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: Array<{ provider: string; id: string }>, scope: ModelSelectionScope): Promise<Record<string, string> | undefined> {
1590
+ async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: ModelPickerMetadata[], scope: ModelSelectionScope, signal?: AbortSignal): Promise<Record<string, string> | undefined> {
1480
1591
  const { persona } = scope;
1481
1592
  const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
1482
1593
  const chosen: Record<string, string> = {};
1594
+ const addedPromptKeys = new Set<string>();
1483
1595
  try {
1484
1596
  for (const p of participants) {
1597
+ if (signal?.aborted) return;
1485
1598
  // Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
1486
- // for this session, exactly like an answer is not asked twice.
1487
- promptedParticipants.add(`${persona}\0${p.key}`);
1599
+ // for this session, exactly like an answer is not asked twice. Aborted setup discards
1600
+ // all its picks, so only its newly added markers are rolled back below.
1601
+ const promptKey = `${persona}\0${p.key}`;
1602
+ if (!promptedParticipants.has(promptKey)) addedPromptKeys.add(promptKey);
1603
+ promptedParticipants.add(promptKey);
1488
1604
  // Name the core's verticalization in the picker too: choosing a model for "the
1489
1605
  // Conservatore" is a different judgement than choosing one for a bare "balthasar".
1490
1606
  // An auxiliary actor names the param that declared it ("arbiter judge").
@@ -1497,14 +1613,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1497
1613
  // In the TUI: a searchable picker (type to filter) whose viewport follows the
1498
1614
  // selection — the built-in select can't scroll a hundreds-long provider list
1499
1615
  // usefully. Outside the TUI (RPC), fall back to the built-in select.
1500
- const pick =
1501
- ctx.mode === "tui"
1616
+ let pick: string | undefined;
1617
+ let cancelPicker: (() => void) | undefined;
1618
+ try {
1619
+ pick = ctx.mode === "tui"
1502
1620
  ? await ctx.ui.custom<string | undefined>(
1503
- (tui, theme, _kb, done) =>
1504
- new ModelPicker(tui, theme, title, options, ctx.model?.provider, (ref) => done(ref)),
1621
+ (tui, theme, keybindings, done) => {
1622
+ // custom() has no signal option: its factory's done callback owns dismissal.
1623
+ cancelPicker = () => done(undefined);
1624
+ if (signal?.aborted) cancelPicker();
1625
+ else signal?.addEventListener("abort", cancelPicker, { once: true });
1626
+ return new ModelPicker(tui, theme, title, options, ctx.model?.provider, (ref) => done(ref), {
1627
+ models: available,
1628
+ ...(p.model !== undefined ? { currentRef: p.model } : {}),
1629
+ ...(keybindings ? { keybindings } : {}),
1630
+ });
1631
+ },
1505
1632
  { overlay: true },
1506
- )
1507
- : await ctx.ui.select(title, options);
1633
+ )
1634
+ : await ctx.ui.select(title, options, signal ? { signal } : undefined);
1635
+ } finally {
1636
+ if (cancelPicker) signal?.removeEventListener("abort", cancelPicker);
1637
+ }
1638
+ if (signal?.aborted) return;
1508
1639
  if (!scope.isCurrent()) {
1509
1640
  ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1510
1641
  return;
@@ -1513,7 +1644,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1513
1644
  }
1514
1645
  } catch {
1515
1646
  /* dismissed / no UI → fall back to the default model */
1647
+ } finally {
1648
+ if (signal?.aborted) for (const key of addedPromptKeys) promptedParticipants.delete(key);
1516
1649
  }
1650
+ if (signal?.aborted) return;
1517
1651
  if (!scope.isCurrent()) {
1518
1652
  ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1519
1653
  return;
@@ -1649,7 +1783,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1649
1783
  // The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
1650
1784
  // `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
1651
1785
  // cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
1652
- function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map()) {
1786
+ function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map(), tracked?: TrackedRun) {
1653
1787
  return {
1654
1788
  onAgentStart: (agent: string, abort: () => void, key?: string) => {
1655
1789
  stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
@@ -1688,6 +1822,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1688
1822
  agentTree.update(id, progressPatch(p, now));
1689
1823
  // Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
1690
1824
  agentTree.update(rootId, { lastAdvanceAt: now });
1825
+ tracked?.onProgress(id, p); // …and the tracked run's own clock and token total, if it has one
1691
1826
  },
1692
1827
  };
1693
1828
  }
@@ -1697,6 +1832,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1697
1832
  return caps ? { canSpawn: (agent) => canDelegateTo(caps, agent) } : {};
1698
1833
  }
1699
1834
 
1835
+ /** The tree root and run-level stop of one visible run. A tracked run (the `council`/`flow` tools) arrives
1836
+ * with both: `async:<runId>` and the signal its stop key aborts. Anything else (a mandated orchestration,
1837
+ * `/orchestrate`, `/flow`) is rooted here and registers its OWN stop under the root's id, so `stopAgent`,
1838
+ * the overlay's `canStop` and shutdown reach the whole run — Pi's `ctx.signal` is undefined while the agent
1839
+ * is idle, which is exactly when those run, so there is no host signal to lean on. `live` is what going
1840
+ * live adds to the root row: a tracked root already exists, "queued", and restarts its clock here. */
1841
+ function visibleRoot(prefix: string, tracked: TrackedRun | undefined): { rootId: string; signal: AbortSignal; live: AgentNodePatch } {
1842
+ if (tracked) {
1843
+ const now = Date.now();
1844
+ return { rootId: tracked.rootId, signal: tracked.signal, live: { detail: "", startedAt: now, lastAdvanceAt: now } };
1845
+ }
1846
+ const ac = new AbortController();
1847
+ const rootId = nextRootId(prefix);
1848
+ stopRegistry.set(rootId, () => ac.abort());
1849
+ return { rootId, signal: ac.signal, live: {} };
1850
+ }
1851
+
1700
1852
  // Run a persona strategy with the unified tree wired in: assign models on first run,
1701
1853
  // seed the roster (cores show by name at once), flip ⏳ → ✓/✗ live, clear when done.
1702
1854
  async function runStrategyVisible(
@@ -1704,34 +1856,39 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1704
1856
  orch: OrchestrationGrammar,
1705
1857
  task: string,
1706
1858
  idPrefix: string,
1707
- signal?: AbortSignal,
1859
+ tracked?: TrackedRun,
1708
1860
  ) {
1709
1861
  const label = resolveStrategyName(orch) ?? "strategy";
1710
1862
  const roster = resolveOrchestrationMembers(orch, teams);
1711
1863
  const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
1712
- await ensurePersonaModels(ctx, participants);
1713
1864
  const participantsByNode = participantByNode(participants);
1714
- const rootId = nextRootId(`${idPrefix}:${label}`);
1715
- agentTree.add({ id: rootId, label, status: "running", kind: "council" });
1716
- // Seed the whole roster at once (cores show by name immediately); "queued" until the
1717
- // engine actually starts each one — an honest view under the concurrency limit.
1718
- const seedKeys = rosterNodeKeys(roster);
1719
- roster.forEach((m, i) => {
1720
- const a = rosterSpec(m).agent;
1721
- const key = seedKeys[i] ?? a;
1722
- const participant = participantForNode(participantsByNode, key);
1723
- const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1724
- agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
1725
- });
1865
+ const { rootId, signal, live } = visibleRoot(`${idPrefix}:${label}`, tracked);
1866
+ agentTree.add({ id: rootId, label, status: "running", kind: "council", ...live });
1726
1867
  try {
1868
+ await ensurePersonaModels(ctx, participants, signal, rootId, tracked);
1869
+ if (signal.aborted) {
1870
+ agentTree.update(rootId, { status: "stopped" });
1871
+ const cancelled: AgentResult = { agent: label, output: "cancelled before orchestration started", usage: emptyUsage(), ok: false, error: "cancelled", failureKind: "abort" };
1872
+ return cancelled;
1873
+ }
1874
+ // Seed the whole roster at once (cores show by name immediately); "queued" until the
1875
+ // engine actually starts each one — an honest view under the concurrency limit.
1876
+ const seedKeys = rosterNodeKeys(roster);
1877
+ roster.forEach((m, i) => {
1878
+ const a = rosterSpec(m).agent;
1879
+ const key = seedKeys[i] ?? a;
1880
+ const participant = participantForNode(participantsByNode, key);
1881
+ const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1882
+ agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
1883
+ });
1727
1884
  // The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
1728
1885
  // cooperatively between rounds, so an aborted run stops convening instead of running every
1729
1886
  // remaining round against an already-cancelled engine (docs/STRATEGIES.md).
1730
- const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal, undefined, { providerFallback: false }), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), ...(signal ? { signal } : {}), ...strategyTreeDeps(ctx, rootId, participantsByNode), ...strategySpawnGate(), ...modelFallbackDep(ctx) });
1731
- agentTree.update(rootId, { status: signal?.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
1887
+ const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), signal, ...strategyTreeDeps(ctx, rootId, participantsByNode, tracked), ...strategySpawnGate(), ...modelFallbackDep(ctx) });
1888
+ agentTree.update(rootId, { status: signal.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
1732
1889
  return result ?? undefined;
1733
1890
  } catch (error) {
1734
- agentTree.update(rootId, { status: signal?.aborted ? "stopped" : "failed" });
1891
+ agentTree.update(rootId, { status: signal.aborted ? "stopped" : "failed" });
1735
1892
  throw error;
1736
1893
  } finally {
1737
1894
  clearStops(rootId);
@@ -1770,12 +1927,45 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1770
1927
  if (found === undefined) return undefined;
1771
1928
  return parseFlow(found);
1772
1929
  }
1930
+ // Installed flows with what a chooser needs: the description and whether a checkpoint pauses
1931
+ // for a human. An unparseable file stays a bare name (running it reports the parse error).
1932
+ function flowCatalog(cwd: string): BriefFlow[] {
1933
+ return listFlows(cwd).map((name): BriefFlow => {
1934
+ const parsed = loadFlow(cwd, name);
1935
+ const flow = parsed?.ok ? parsed.flow : undefined;
1936
+ return {
1937
+ name,
1938
+ ...(flow?.description ? { description: flow.description } : {}),
1939
+ ...(flow?.phases.some((p) => p.gate) ? { gated: true } : {}),
1940
+ };
1941
+ });
1942
+ }
1943
+
1944
+ // Pi's extension selector is ONE slot: a second concurrent `ctx.ui.select` displaces the first without ever
1945
+ // resolving it, orphaning a run behind a prompt nobody can see. Background model setup and flow checkpoints
1946
+ // share this line, so their prompts open one at a time in arrival order. A stopped run leaves it at once.
1947
+ let gateLine: Promise<unknown> = Promise.resolve();
1948
+ function inGateLine<T>(signal: AbortSignal, stopped: T, open: () => Promise<T>): Promise<T> {
1949
+ const turn = gateLine.then(() => (signal.aborted ? stopped : open()));
1950
+ gateLine = turn.catch(() => undefined);
1951
+ let onAbort: (() => void) | undefined;
1952
+ const left = new Promise<T>((resolve) => {
1953
+ if (signal.aborted) resolve(stopped);
1954
+ else {
1955
+ onAbort = () => resolve(stopped);
1956
+ signal.addEventListener("abort", onAbort, { once: true });
1957
+ }
1958
+ });
1959
+ return Promise.race([turn, left]).finally(() => {
1960
+ if (onAbort) signal.removeEventListener("abort", onAbort);
1961
+ });
1962
+ }
1773
1963
 
1774
1964
  // Run a flow's DAG with the unified tree (phases as nodes, cores beneath) + journaled
1775
- // resume: a prior run's journal (keyed by flow@hash) skips already-done phases; the
1965
+ // resume: a prior run's journal (keyed by flow + task + cwd) skips already-done phases; the
1776
1966
  // journal is cleared on a fully-successful run.
1777
- async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string, signal?: AbortSignal): Promise<FlowOutcome> {
1778
- const hash = flowHash(spec);
1967
+ async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string, tracked?: TrackedRun): Promise<FlowOutcome> {
1968
+ const hash = flowRunHash(spec, baseTask, ctx.cwd);
1779
1969
  const journalDir = join(personaDataDir(), "flows");
1780
1970
  try {
1781
1971
  mkdirSync(journalDir, { recursive: true });
@@ -1801,17 +1991,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1801
1991
  return [phase.id, { members, participants }] as const;
1802
1992
  }),
1803
1993
  );
1804
- await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []));
1805
-
1806
- const flowRoot = nextRootId(`flow:${spec.name}`);
1807
- agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
1808
- for (const p of spec.phases) {
1809
- const pid = `${flowRoot}/${p.id}`;
1810
- const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
1811
- if (resume[p.id]) node.detail = "resumed";
1812
- agentTree.add(node);
1813
- }
1994
+ const { rootId: flowRoot, signal, live } = visibleRoot(`flow:${spec.name}`, tracked);
1995
+ agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow", ...live });
1814
1996
  try {
1997
+ await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []), signal, flowRoot, tracked);
1998
+ if (signal.aborted) {
1999
+ agentTree.update(flowRoot, { status: "stopped" });
2000
+ return { ok: false, results: {}, output: "cancelled before flow started", error: "cancelled", failureKind: "abort", cancelled: true };
2001
+ }
2002
+ for (const p of spec.phases) {
2003
+ const pid = `${flowRoot}/${p.id}`;
2004
+ const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
2005
+ if (resume[p.id]) node.detail = "resumed";
2006
+ agentTree.add(node);
2007
+ }
1815
2008
  // Yield so a concurrent flow can seed its own tree before we fail-closed on refs —
1816
2009
  // the check still runs BEFORE any phase/engine work.
1817
2010
  await Promise.resolve();
@@ -1834,7 +2027,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1834
2027
  const outcome = await runFlow(spec, baseTask, {
1835
2028
  hash,
1836
2029
  resume,
1837
- ...(signal ? { signal } : {}),
2030
+ signal,
1838
2031
  journal: journalWriter(journalPath),
1839
2032
  onPhase: (id, status, result) => {
1840
2033
  const patch: { status: AgentNodeStatus; output?: string; detail?: string } = {
@@ -1860,12 +2053,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1860
2053
  const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
1861
2054
  if (phase.roster) orch.roster = phase.roster;
1862
2055
  const r = await runPersonaStrategy(orch, task, {
1863
- engine: buildEngine(signal, undefined, { providerFallback: false }),
2056
+ engine: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }),
1864
2057
  teams,
1865
2058
  limits: RUN_LIMITS,
1866
2059
  ...sessionModelDep(ctx),
1867
- ...(signal ? { signal } : {}), // cooperative per-round abort inside the phase's strategy, not just the engine
1868
- ...strategyTreeDeps(ctx, pid, participantsByNode),
2060
+ signal, // cooperative per-round abort inside the phase's strategy, not just the engine
2061
+ ...strategyTreeDeps(ctx, pid, participantsByNode, tracked),
1869
2062
  ...strategySpawnGate(),
1870
2063
  ...modelFallbackDep(ctx),
1871
2064
  });
@@ -1876,26 +2069,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1876
2069
  // boundary, never an informational prompt. Approval is journaled for resume.
1877
2070
  approveGate: async (phase, result) => {
1878
2071
  if (!ctx.hasUI) return false;
1879
- const preview = result.output.replace(/\s+/g, " ").slice(0, 160);
2072
+ const next = spec.phases.filter((p) => p.needs?.includes(phase.id));
2073
+ // The run is the OPERATOR's to release here, not the model's: say so on the tree row and, for a
2074
+ // tracked run, on its peek line — silence at a modal is neither a stall nor a reason to wake anyone.
2075
+ const waiting = `waiting for operator at checkpoint ${phase.id}`;
2076
+ agentTree.update(flowRoot, { detail: waiting });
2077
+ tracked?.onAwaiting(waiting);
1880
2078
  try {
1881
- const pick = await ctx.ui.select(`Checkpoint "${phase.id}" — approve and continue the flow?\n${preview}`, ["Approve", "Reject"]);
2079
+ // Reject first: a stray Enter on the default row fails closed. The run signal dismisses
2080
+ // the prompt, so a stopped flow is not held open by a question nobody will answer.
2081
+ const pick = await inGateLine(signal, undefined, () =>
2082
+ ctx.ui.select(gateReviewText({ flow: spec.name, phase: phase.id, next, output: result.output }), ["Reject", "Approve"], { signal }),
2083
+ );
1882
2084
  return pick === "Approve";
1883
2085
  } catch {
1884
2086
  return false;
2087
+ } finally {
2088
+ tracked?.onAwaiting(undefined);
2089
+ agentTree.update(flowRoot, { detail: "", lastAdvanceAt: Date.now() });
1885
2090
  }
1886
2091
  },
1887
2092
  });
1888
- if (outcome.ok) {
1889
- try {
1890
- rmSync(journalPath, { force: true }); // clean journal once the whole flow succeeds
1891
- } catch {
1892
- /* ignore */
1893
- }
1894
- }
1895
- agentTree.update(flowRoot, { status: signal?.aborted ? "stopped" : outcome.ok ? "done" : "failed" });
2093
+ if (outcome.ok) clearJournal(journalPath, hash); // this run is finished; other tasks' progress stays
2094
+ // A rejected checkpoint is a pause the operator chose, not a failure (same call as the flow tool).
2095
+ const disposition = flowDisposition(outcome);
2096
+ agentTree.update(flowRoot, { status: disposition === "failed" && signal.aborted ? "stopped" : disposition });
1896
2097
  return outcome;
1897
2098
  } catch (error) {
1898
- agentTree.update(flowRoot, { status: signal?.aborted ? "stopped" : "failed" });
2099
+ agentTree.update(flowRoot, { status: signal.aborted ? "stopped" : "failed" });
1899
2100
  throw error;
1900
2101
  } finally {
1901
2102
  clearStops(flowRoot);
@@ -1904,15 +2105,53 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1904
2105
  }
1905
2106
  }
1906
2107
 
1907
- // Search/list available models (provider/id), session provider first (★).
1908
- function searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean } {
2108
+ // Discovery is acquired once per base model/session. Repeated turns and nudges reuse it;
2109
+ // explicit discovery/refresh replaces it. Execution preflights still consult the live registry.
2110
+ let modelCatalog: { key: string; models: ModelCostMetadata[]; candidates: string | undefined } | undefined;
2111
+ function modelCatalogKey(ctx: ExtensionContext): string {
2112
+ return `${ctx.sessionManager?.getSessionId?.() ?? ""}\0${ctx.model?.provider ?? ""}\0${ctx.model?.id ?? ""}`;
2113
+ }
2114
+ async function refreshModelCatalog(ctx: ExtensionContext, signal: AbortSignal) {
2115
+ // Pi contexts have live getters: capture before awaiting so an old refresh cannot
2116
+ // discard the snapshot acquired after a session or base-model switch.
2117
+ const key = modelCatalogKey(ctx);
2118
+ try {
2119
+ return await ctx.modelRegistry.refresh?.({ signal });
2120
+ } finally {
2121
+ // A failed/aborted refresh may still have updated some providers.
2122
+ if (modelCatalog?.key === key) modelCatalog = undefined;
2123
+ }
2124
+ }
2125
+ function canDiscoverModels(): boolean {
2126
+ const caps = controller.capabilities;
2127
+ return pi.getActiveTools().includes("models") && (!caps || canCallTool(caps, "models"));
2128
+ }
2129
+ function currentModelCatalog(ctx: ExtensionContext, refresh = false) {
2130
+ const key = modelCatalogKey(ctx);
2131
+ if (refresh || modelCatalog?.key !== key) {
2132
+ const models = configuredModels(ctx);
2133
+ modelCatalog = { key, models, candidates: renderNudgeModels(models, ctx.model) };
2134
+ }
2135
+ return modelCatalog;
2136
+ }
2137
+
2138
+ // Default discovery stays near the base model; explicit searches keep the full eligible catalog.
2139
+ function searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean; shortlist: boolean } {
1909
2140
  const session = ctx.model?.provider;
1910
2141
  const isSession = (ref: string): boolean => session !== undefined && ref.startsWith(`${session}/`);
1911
- const all = configuredModels(ctx).map((m) => `${m.provider}/${m.id}`);
1912
- const filtered = orderModelRefs(filterModels(all, query ?? ""), session);
2142
+ const catalog = currentModelCatalog(ctx, true).models;
2143
+ const metadata = new Map(catalog.map((m) => [`${m.provider}/${m.id}`, m]));
2144
+ const tiers = modelCostTiers(catalog, shortlistModels(catalog, undefined, Number.MAX_SAFE_INTEGER));
2145
+ const shortlist = !query?.trim();
2146
+ const all = catalog.map((m) => `${m.provider}/${m.id}`);
2147
+ const filtered = orderModelCandidates(filterModels(all, query ?? "").map((ref) => metadata.get(ref)!), ctx.model).map((m) => `${m.provider}/${m.id}`);
1913
2148
  const cap = 40;
1914
- const lines = filtered.slice(0, cap).map((r) => `${isSession(r) ? "★ " : " "}${r}`);
1915
- return { lines, total: filtered.length, capped: filtered.length > cap };
2149
+ const shown = shortlist
2150
+ ? recommendModels(catalog, ctx.model, cap).map((m) => `${m.provider}/${m.id}`)
2151
+ : filtered.slice(0, cap);
2152
+ const self = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
2153
+ const lines = shown.map((r) => `${isSession(r) ? "★ " : " "}${r} ${modelCandidateDetails(metadata.get(r)!, tiers.get(r))}${r === self ? " (you)" : ""}`);
2154
+ return { lines, total: filtered.length, capped: filtered.length > lines.length, shortlist };
1916
2155
  }
1917
2156
 
1918
2157
  // The per-turn delegation brief (core/brief.ts): live roster + standing hand-off default,
@@ -1920,11 +2159,19 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1920
2159
  // persona line has no force, and regenerated from the live registry so it cannot desync.
1921
2160
  // Capability-aware: a persona that denies `delegate` gets none, and the agent list is
1922
2161
  // filtered to the persona's allowed targets.
2162
+ /** The installed agents the active capabilities allow as delegate targets (all, with no persona). */
2163
+ function delegateTargets(): AgentConfig[] {
2164
+ const caps = controller.capabilities;
2165
+ return caps ? agents.filter((a) => canDelegateTo(caps, a.name)) : agents;
2166
+ }
2167
+
1923
2168
  function delegationBrief(ctx: ExtensionContext): string | undefined {
1924
2169
  const caps = controller.capabilities;
1925
2170
  if (caps && !canFanOut(caps)) return undefined;
1926
- const targets = caps ? agents.filter((a) => canDelegateTo(caps, a.name)) : agents;
2171
+ const targets = delegateTargets();
1927
2172
  const delegation = controller.activePersona?.delegation;
2173
+ const canListModels = canDiscoverModels();
2174
+ const candidates = canListModels && ctx.model ? currentModelCatalog(ctx).candidates : undefined;
1928
2175
  const teamAgents: Record<string, string[]> = {};
1929
2176
  for (const [name, members] of Object.entries(teams)) teamAgents[name] = members.map((m) => rosterSpec(m).agent);
1930
2177
  return buildDelegationBrief({
@@ -1937,11 +2184,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1937
2184
  ...(a.isolation ? { isolation: a.isolation } : {}),
1938
2185
  })),
1939
2186
  teams: teamAgents,
1940
- flows: listFlows(ctx.cwd),
2187
+ // Methods are only taught to a persona that can call them: an allowlist without `council`/`flow` is not
2188
+ // told to convene one (the call would be refused by the capability gate).
2189
+ flows: !caps || canCallTool(caps, "flow") ? flowCatalog(ctx.cwd) : [],
2190
+ ...(caps && !canCallTool(caps, "council") ? { council: false } : {}),
2191
+ canListModels,
2192
+ ...(candidates ? { modelCandidates: candidates } : {}),
1941
2193
  standing: controller.activePersona !== undefined,
1942
- // Interactive sessions delegate in the background by default; headless (`pi -p`)
1943
- // stays sync (the single turn must carry the result) — mirror that in the copy.
1944
- asyncDefault: ctx.hasUI === true,
2194
+ // Interactive sessions ALWAYS delegate in the background; headless (`pi -p`) always
2195
+ // blocks (the single turn must carry the result) — mirror that in the copy.
2196
+ background: ctx.hasUI === true,
1945
2197
  ...(delegation?.requireBrief !== undefined ? { requireBrief: delegation.requireBrief } : {}),
1946
2198
  ...(delegation?.outputContract ? { outputContract: delegation.outputContract } : {}),
1947
2199
  ...(delegation?.requireDisjointWrites !== undefined ? { requireDisjointWrites: delegation.requireDisjointWrites } : {}),
@@ -1993,7 +2245,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1993
2245
  lines.push(`data root migration: moved ${dataRootMigration.moved.length}, kept ${dataRootMigration.kept.length} (legacy pi-persona root folded into ${personaDataDir()})`);
1994
2246
  }
1995
2247
  for (const warning of dataRootMigration.warnings) lines.push(warning);
1996
- lines.push(`personas (${personas.length}): ${personas.map((p) => p.name).join(", ") || "—"}`);
2248
+ lines.push(`personas (${personas.length}): ${personas.map((p) => p.name).join(", ") || NO_PERSONAS_HINT}`);
1997
2249
  lines.push(`agents (${agents.length}): ${agents.map((a) => a.name).join(", ") || "—"}`);
1998
2250
  if (definitionCollisions.length > 0) {
1999
2251
  lines.push(`definition collisions (${definitionCollisions.length}, omitted): ${definitionCollisions.map((collision) => collision.name).join(", ")}`);
@@ -2048,7 +2300,14 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2048
2300
  get lastCtx() { return lastCtx; },
2049
2301
  set lastCtx(value) { lastCtx = value; },
2050
2302
  get disposed() { return disposed; },
2051
- set disposed(value) { disposed = value; },
2303
+ set disposed(value) {
2304
+ disposed = value;
2305
+ if (value) {
2306
+ cancelAgentWidgetRender();
2307
+ widgetClock.stop();
2308
+ wakeRows.clearMonitors();
2309
+ }
2310
+ },
2052
2311
  get orchestrating() { return orchestrating; },
2053
2312
  set orchestrating(value) { orchestrating = value; },
2054
2313
  get processingDeferredOrchestration() { return processingDeferredOrchestration; },
@@ -2068,6 +2327,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2068
2327
  telemetryAgent,
2069
2328
  delegationNudge,
2070
2329
  persistenceNudge,
2330
+ pollGuard,
2331
+ tracker,
2071
2332
  appendNudgeEntry,
2072
2333
  completionNotifier,
2073
2334
  intercomNotifier,
@@ -2113,6 +2374,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2113
2374
  };
2114
2375
  installHooks(pi, hookHost, exocom);
2115
2376
 
2377
+ // ONE launch pool for every background run: a delegate leg, a `council` and a `flow` each take a slot
2378
+ // (maxConcurrency). The strategy SDK's own per-run semaphore gates the legs INSIDE a council or flow.
2379
+ const launchPool = new Semaphore(RUN_LIMITS.maxConcurrency);
2380
+ const backgroundRuns = createBackgroundRuns({
2381
+ tracker,
2382
+ agentTree,
2383
+ stopRegistry,
2384
+ slots: launchPool,
2385
+ completionNotifier,
2386
+ startPeek,
2387
+ childUsage,
2388
+ publishPersonaCost,
2389
+ });
2390
+
2116
2391
  registerDelegateTool(pi, {
2117
2392
  get lastCtx() { return lastCtx; },
2118
2393
  set lastCtx(value) { lastCtx = value; },
@@ -2121,21 +2396,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2121
2396
  contractNames: () => installedContractNames(contractDefs),
2122
2397
  buildEngine,
2123
2398
  agentTree,
2124
- nextRootId,
2125
2399
  tracker,
2400
+ runs: backgroundRuns,
2401
+ launchPool,
2126
2402
  config: { ledgerV2: config.ledgerV2 },
2127
2403
  RUN_LIMITS,
2128
2404
  publishAgentTool,
2129
2405
  stopRegistry,
2130
2406
  steerRegistry,
2131
- stopRequested,
2132
- ensurePersonaModels,
2133
- clearStops,
2134
- clearSteers,
2135
2407
  drainBusBlock,
2136
2408
  startPeek,
2137
- childUsage,
2138
- publishPersonaCost,
2139
2409
  });
2140
2410
 
2141
2411
  // ── f8 cycle ────────────────────────────────────────────────────────────────
@@ -2143,7 +2413,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2143
2413
  description: "Cycle persona (pi-persona)",
2144
2414
  handler: async (ctx: ExtensionContext) => {
2145
2415
  lastCtx = ctx;
2146
- if (personas.length === 0) return;
2416
+ if (personas.length === 0) {
2417
+ if (ctx.hasUI) ctx.ui.notify(NO_PERSONAS_HINT, "info");
2418
+ return;
2419
+ }
2147
2420
  const current = controller.activePersona;
2148
2421
  const idx = current ? personas.findIndex((p) => p.name === current.name) : -1;
2149
2422
  const next = idx + 1;
@@ -2181,14 +2454,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2181
2454
  SUPERVISOR,
2182
2455
  STALL_FLAG_MS,
2183
2456
  waitingRunIds: waitingAsyncRunIds,
2457
+ pollGuard,
2184
2458
  missingRunMessage,
2185
2459
  stopAgent,
2186
2460
  steerAgent,
2187
2461
  steerRegistry,
2188
2462
  stopRequested,
2189
- drainBusBlock,
2190
2463
  scanForSurrender,
2191
- get disposed() { return disposed; },
2192
2464
  childUsage,
2193
2465
  publishPersonaCost,
2194
2466
  });
@@ -2203,6 +2475,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2203
2475
  set lastCtx(value) { lastCtx = value; },
2204
2476
  canRun: canRunMonitor,
2205
2477
  monitors,
2478
+ archive: monitorArchive,
2206
2479
  });
2207
2480
 
2208
2481
  registerCouncilTool(pi, {
@@ -2211,9 +2484,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2211
2484
  controller,
2212
2485
  get personas() { return personas; },
2213
2486
  runStrategyVisible,
2487
+ runs: backgroundRuns,
2214
2488
  drainBusBlock,
2215
- childUsage,
2216
- publishPersonaCost,
2217
2489
  });
2218
2490
 
2219
2491
  registerFlowTool(pi, {
@@ -2222,8 +2494,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2222
2494
  loadFlow,
2223
2495
  listFlows,
2224
2496
  runFlowVisible,
2225
- childUsage,
2226
- publishPersonaCost,
2497
+ runs: backgroundRuns,
2227
2498
  });
2228
2499
 
2229
2500
  // ── f9: navigable agent overlay ──────────────────────────────────────────────
@@ -2298,6 +2569,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2298
2569
  personaConfigs = savedSettings.personas;
2299
2570
  },
2300
2571
  isCurrent: () => !disposed && (session === undefined || lastCtx?.sessionManager?.getSessionId?.() === session),
2572
+ refreshCatalog: (signal) => refreshModelCatalog(ctx, signal),
2301
2573
  });
2302
2574
  },
2303
2575
  });
@@ -2330,7 +2602,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2330
2602
  const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
2331
2603
  const deadline = AbortSignal.timeout(20_000);
2332
2604
  const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
2333
- const result = await ctx.modelRegistry.refresh?.({ signal });
2605
+ const result = await refreshModelCatalog(ctx, signal);
2334
2606
  if (!scope.isCurrent()) {
2335
2607
  ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
2336
2608
  return;
@@ -2402,14 +2674,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2402
2674
  return;
2403
2675
  }
2404
2676
  if (arg === "" || arg === "list") {
2405
- // Empty by design on a fresh install: personas load only from the user dir, which the
2406
- // user populates on purpose. Point them at the install gesture rather than leaving a
2407
- // bare "(none)" that reads like something is broken.
2408
2677
  if (personas.length === 0) {
2409
- ctx.ui.notify(
2410
- "No personas installed. pi-persona does not auto-install — run `/persona seed` to copy the bundled defaults into your agent dir (edit them freely; `/persona restore` re-installs the originals).",
2411
- "info",
2412
- );
2678
+ ctx.ui.notify(NO_PERSONAS_HINT, "info");
2413
2679
  return;
2414
2680
  }
2415
2681
  const lines = personas.map(
@@ -2491,16 +2757,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2491
2757
 
2492
2758
  // ── /models + a `models` tool — discover/search model ids ─────────────────────
2493
2759
  pi.registerCommand("models", {
2494
- description: "List or search available models (provider/id): /models [query]",
2760
+ description: "Show model candidates or search the full catalog (provider/id): /models [query]",
2495
2761
  handler: async (args, ctx) => {
2496
2762
  lastCtx = ctx;
2497
- const { lines, total, capped } = searchModels(ctx, args);
2763
+ const { lines, total, capped, shortlist } = searchModels(ctx, args);
2498
2764
  if (lines.length === 0) {
2499
2765
  ctx.ui.notify(`models: no match for "${args.trim()}"`, "warning");
2500
2766
  return;
2501
2767
  }
2502
- const head = `models (${total}${capped ? ", showing 40" : ""})${ctx.model ? ` · session ★ ${ctx.model.provider}/${ctx.model.id}` : ""}`;
2503
- ctx.ui.notify(`${head}\n${lines.join("\n")}${capped ? "\n…refine with /models <query>" : ""}`, "info");
2768
+ const head = `models (${total}${shortlist || capped ? `, showing ${lines.length}${shortlist ? " nearby family/version candidates" : " matches"}` : ""})${ctx.model ? ` · session ★ ${ctx.model.provider}/${ctx.model.id}` : ""}`;
2769
+ ctx.ui.notify(`${head}\n${lines.join("\n")}\nAccount support may vary.${shortlist || capped ? " Search the full catalog with /models <query>." : ""}`, "info");
2504
2770
  },
2505
2771
  });
2506
2772
 
@@ -2548,11 +2814,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2548
2814
  return;
2549
2815
  }
2550
2816
  try {
2551
- const outcome = await runFlowVisible(ctx, parsed.flow, task);
2552
- appendCommandResult(`flow ${orch.flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
2553
- ...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
2554
- ...(outcome.error ? { error: outcome.error } : {}),
2555
- });
2817
+ appendFlowResult(orch.flow, await runFlowVisible(ctx, parsed.flow, task));
2556
2818
  } catch (err) {
2557
2819
  ctx.ui.notify(`orchestrate failed: ${err instanceof Error ? err.message : String(err)}`, "error");
2558
2820
  }
@@ -2571,7 +2833,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2571
2833
  }
2572
2834
  try {
2573
2835
  const result = await runStrategyVisible(ctx, orch, task, "orchestrate");
2574
- appendCommandResult(
2836
+ appendOperatorResult(
2575
2837
  `orchestrate ${strategyName}`,
2576
2838
  result?.output || "(no output)",
2577
2839
  result?.ok ?? false,
@@ -2593,9 +2855,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2593
2855
  lastCtx = ctx;
2594
2856
  const trimmed = args.trim();
2595
2857
  if (!trimmed || trimmed === "list") {
2596
- const flows = listFlows(ctx.cwd);
2858
+ const flows = flowCatalog(ctx.cwd);
2597
2859
  ctx.ui.notify(
2598
- flows.length ? `flows: ${flows.join(", ")} · /flow <name> <task>` : "no flows — add a *.flow.json under .pi/flows/",
2860
+ flows.length
2861
+ ? [
2862
+ "flows — /flow <name> <task>",
2863
+ ...flows.map((f) =>
2864
+ ` ${f.name}${f.description ? ` — ${compactInlineText(f.description, { maxChars: 96 })}` : ""}${f.gated ? " [pauses at a checkpoint for your approval]" : ""}`),
2865
+ ].join("\n")
2866
+ : "no flows found — the bundled ones did not load (check the pi-persona install); your own go in .pi/flows/*.flow.json",
2599
2867
  "info",
2600
2868
  );
2601
2869
  return;
@@ -2617,11 +2885,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2617
2885
  return;
2618
2886
  }
2619
2887
  try {
2620
- const outcome = await runFlowVisible(ctx, parsed.flow, task);
2621
- appendCommandResult(`flow ${name}`, outcome.output || "(flow produced no output)", outcome.ok, {
2622
- ...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
2623
- ...(outcome.error ? { error: outcome.error } : {}),
2624
- });
2888
+ appendFlowResult(name, await runFlowVisible(ctx, parsed.flow, task));
2625
2889
  } catch (err) {
2626
2890
  ctx.ui.notify(`flow failed: ${err instanceof Error ? err.message : String(err)}`, "error");
2627
2891
  }