@aeondave/pi-persona 1.17.0 → 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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -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 +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  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 +16 -11
  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 +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
package/src/extension.ts CHANGED
@@ -11,16 +11,18 @@
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";
23
24
  import { resolveConfig } from "./core/config.ts";
25
+ import { resolvePersonaConfig } from "./core/settings.ts";
24
26
  import { type ContractDef, installedContractNames } from "./core/contract.ts";
25
27
  import { type DataRootMigrationResult, migrateLegacyDataRoot } from "./core/data-root.ts";
26
28
  import {
@@ -34,11 +36,14 @@ import {
34
36
  type SpineLegacyResult,
35
37
  type SpineLegacySelection,
36
38
  } from "./core/seed.ts";
37
- import { buildDelegationBrief } from "./core/brief.ts";
39
+ import { type BriefFlow, buildDelegationBrief } from "./core/brief.ts";
38
40
  import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
39
41
  import { fenceUntrusted } from "./core/fence.ts";
40
42
  import { sanitizeDisplayLabel } from "./core/display-label.ts";
41
- 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";
42
47
  import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-compat.ts";
43
48
  import { type EngineAdapterBroker } from "./engine/adapter.ts";
44
49
  import { SupervisorBroker } from "./extension/broker-host.ts";
@@ -52,7 +57,6 @@ import {
52
57
  type PendingAsk,
53
58
  } from "./extension/shared.ts";
54
59
  export {
55
- agentNodeStatusForDelegate,
56
60
  boundExocomInboundBatch,
57
61
  canDeliverPersonaNotification,
58
62
  canonicalExocomTelemetryTargets,
@@ -77,25 +81,31 @@ export {
77
81
  } from "./extension/shared.ts";
78
82
  import { installExocom } from "./exocom/install.ts";
79
83
  import { installIdentity } from "./extension/identity.ts";
84
+ import { registerPersonaSettings } from "./extension/persona-settings.ts";
85
+ import { openPersonaModelSettings } from "./extension/persona-model-settings.ts";
86
+ import { collectCompanionSettings } from "./extension/companion-settings.ts";
80
87
  import { parseExocomArgv } from "./exocom/activation.ts";
81
88
  import { formatLedgerStatus } from "./exocom/status.ts";
82
89
  import { registerDelegateTool } from "./tools/delegate-tool.ts";
83
- import { registerIntercomTool } from "./tools/intercom-tool.ts";
90
+ import { describeRuns, registerIntercomTool } from "./tools/intercom-tool.ts";
84
91
  import { registerTimerTool } from "./tools/timer.ts";
85
- import { registerMonitorTool } from "./tools/monitor.ts";
92
+ import { registerMonitorTool, renderMonitorLog } from "./tools/monitor.ts";
93
+ import { MonitorArchive } from "./monitor/archive.ts";
86
94
  import { createMonitorSession, type MonitorSession } from "./monitor/session.ts";
95
+ import { createBackgroundRuns, type TrackedRun } from "./tools/background-run.ts";
87
96
  import { registerCouncilTool } from "./tools/council.ts";
88
97
  import { registerFlowTool } from "./tools/flow.ts";
89
98
  import { registerModelsTool } from "./tools/models.ts";
90
- import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, 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";
91
100
  import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
92
101
  import { brokerEndpoint } from "./bus/broker/paths.ts";
93
102
  import { InProcessBus } from "./bus/inproc.ts";
94
103
  import { loadContracts, loadDefinitions, loadPresets, loadTeams, type LoadResult, type ScopedDir } from "./loader.ts";
95
- import { type FlowSpec, flowHash, parseFlow, verifyFlowRefs } from "./orchestration/flow.ts";
96
- 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";
97
106
  import { runFlow } from "./orchestration/flow-run.ts";
98
- 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";
99
109
  import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
100
110
  import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
101
111
  import { knownParams, strategyNames } from "./orchestration/strategy.ts";
@@ -110,27 +120,27 @@ import {
110
120
  } from "./persona/model-participants.ts";
111
121
  import { expandCouncilPreset, resolveCouncilInvocation, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
112
122
  import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
113
- import { readLastPersona, writeLastPersona } from "./persona/state.ts";
123
+ import { migratePersonaSettings, patchPersonaSettings, readPersonaSettings, type PersonaSettings } from "./persona/settings-store.ts";
114
124
  import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
115
125
  import { TelemetryProducer, type TelemetryAgentInput } from "./telemetry/producer.ts";
116
126
  import {
117
127
  type PersonaConfigStore,
118
128
  personaModels,
119
- readPersonaConfigs,
120
129
  withPersonaModels,
121
- writePersonaConfigs,
122
130
  } from "./persona/config-store.ts";
123
131
  import { shortModel } from "./tools/delegate.ts";
124
132
  import { formatInbox } from "./tools/intercom.ts";
125
133
  import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
126
134
  import { AgentOverlay } from "./ui/agent-overlay.ts";
127
- 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";
128
137
  import { LiveClock } from "./ui/live-clock.ts";
129
- import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
130
- 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";
131
141
  import { ChildUsageLedger, formatPersonaCostStatus, formatUsage, PERSONA_COST_STATUS_KEY } from "./ui/usage.ts";
132
142
 
133
- const RUN_LIMITS: RunLimits = {
143
+ export const RUN_LIMITS: RunLimits = {
134
144
  // A generous anti-runaway backstop, not a council-size cap: a declared ensemble
135
145
  // (e.g. 9 members × several rounds) must fit; the token budget is the real cost guard.
136
146
  maxChildren: 64,
@@ -173,6 +183,8 @@ interface CommandResultEntry {
173
183
  label: string;
174
184
  content: string;
175
185
  ok: boolean;
186
+ /** A flow that paused at a rejected checkpoint: not a failure, so it is titled "stopped". */
187
+ stoppedAtGate?: string;
176
188
  failureKind?: FailureKind;
177
189
  error?: string;
178
190
  }
@@ -183,10 +195,20 @@ interface NudgeEntry {
183
195
 
184
196
  interface CheckInEntry {
185
197
  content: string;
198
+ kind?: "routine" | "stall";
199
+ }
200
+
201
+ interface RunFailureEntry {
202
+ id: string;
203
+ label: string;
204
+ agent: string;
205
+ model?: string;
206
+ durationMs?: number;
207
+ error: string;
186
208
  }
187
209
 
188
210
  /**
189
- * 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
190
212
  * path. A supervisor-requested abort is terminal, but it is not an execution error: the tracker
191
213
  * classifies it as `stopped`, so the UI reports it at info level while the completion still reaches
192
214
  * the supervisor exactly once through the normal notifier.
@@ -195,31 +217,51 @@ export function announceAsyncRunSettlement(
195
217
  run: AsyncRun,
196
218
  notify: ((message: string, level: "info" | "error") => void) | undefined,
197
219
  enqueue: (run: AsyncRun) => void,
220
+ presentFailure?: (run: AsyncRun) => void,
198
221
  ): void {
199
222
  const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
200
223
  const agent = compactInlineText(run.label ?? run.agent, { maxChars: 80 }) || compactInlineText(run.agent, { maxChars: 80 }) || "agent";
201
224
  try {
202
225
  if (run.status === "done") notify?.(`async run ${id} (${agent}) completed`, "info");
203
226
  else if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
204
- 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
+ }
205
234
  } catch {
206
235
  // Cosmetic notification only: a broken UI must never swallow the terminal report.
207
236
  }
208
237
  enqueue(run);
209
238
  }
210
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
+
211
251
  /** Agents actually IN FLIGHT: a RUNNING node with no children of its own. "Has a parent" is not the
212
252
  * same as "is an agent" — a flow phase is a container that also has a parent, a root-level `async:`
213
253
  * run is a leaf that has none, and a settled leg still sits in the tree until its root is torn
214
- * down. Exported for direct unit testing (mirrors `listPeersForGroup` above). */
215
- 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 {
216
258
  const parents = new Set<string>();
217
259
  for (const node of nodes) {
218
260
  if (node.parentId !== undefined) parents.add(node.parentId);
219
261
  }
220
262
  let count = 0;
221
263
  for (const node of nodes) {
222
- if (node.status === "running" && !parents.has(node.id)) count++;
264
+ if (node.status === "running" && node.kind !== "delegate" && !isWakeSource(node) && !parents.has(node.id)) count++;
223
265
  }
224
266
  return count;
225
267
  }
@@ -279,99 +321,52 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
279
321
  return;
280
322
  }
281
323
 
282
- const config = resolveConfig(process.env);
324
+ let config = resolveConfig(process.env);
283
325
  if (config.disabled) return;
284
326
  const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
285
- const registerFollowUpRenderer = (customType: "pi-persona" | "exocom_received", label: string): void => {
286
- pi.registerMessageRenderer(customType, (message, { expanded, outputPad }, theme) => {
287
- const raw = typeof message.content === "string"
288
- ? message.content
289
- : message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
290
- const full = sanitizeTerminalText(raw);
291
- const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
292
- const firstLine = full.split("\n", 1)[0] ?? "";
293
- // External peer text is data and must never choose our UI chrome. Internal follow-ups own
294
- // their first status line; parse only that line and do not turn a healthy "0 failed" red.
295
- const isFailure = customType === "pi-persona"
296
- && !/\b0 failed\b/i.test(firstLine)
297
- && /\b(?:failed|failure|error|rejected|unresolved)\b/i.test(firstLine);
298
- const titleColor = isFailure ? "error" : customType === "exocom_received" ? "accent" : "success";
299
- const title = theme.fg(titleColor, theme.bold(label));
300
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full || "(no output)")}`, outputPad, 0);
301
- const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
302
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, outputPad, 0);
303
- });
304
- };
305
- // These follow-ups remain complete in the model context, but the human-facing card starts as a
306
- // bounded digest. This is the central progressive-disclosure boundary for async completions and
307
- // external peer messages; expanding the card reveals the exact sanitized payload.
308
- registerFollowUpRenderer("pi-persona", "pi-persona · update");
309
- registerFollowUpRenderer("exocom_received", "exocom · inbound");
310
- pi.registerEntryRenderer("pi-persona-result", (entry, { expanded }, theme) => {
311
- const data = entry.data as CommandResultEntry;
312
- const full = sanitizeTerminalText(data.content || "(no output)");
313
- const cause = compactVisibleText(
314
- [data.failureKind, data.error].filter((part): part is string => !!part).join(" · "),
315
- { maxLines: 1, maxLineChars: 80 },
316
- ).text;
317
- const status = data.ok ? "complete" : "failed";
318
- const label = compactInlineText(data.label, { maxChars: 96 }) || "result";
319
- const title = theme.fg(data.ok ? "success" : "error", theme.bold(`${label} · ${status}${!data.ok && cause !== "(no output)" ? ` · ${cause}` : ""}`));
320
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
321
- const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
322
- const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
323
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
324
- });
325
- pi.registerEntryRenderer("pi-persona-nudge", (entry, { expanded }, theme) => {
326
- const data = entry.data as NudgeEntry;
327
- const full = sanitizeTerminalText(data.content || "⟢ pi-persona · checkpoint");
328
- const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
329
- const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
330
- const details = lines.slice(1);
331
- const title = theme.fg("warning", theme.bold(`⟢ ${header}`));
332
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", details.join("\n") || "(no detail)")}`, 0, 0);
333
- const trigger = details.find((line) => line.startsWith("Trigger:"));
334
- const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
335
- const selected = [trigger, action].filter((line): line is string => line !== undefined);
336
- const previewLines = selected.length > 0 ? selected : details.slice(0, 2);
337
- const preview = compactVisibleText(previewLines.join("\n"), { maxLines: 2, maxLineChars: 100 });
338
- const hasMore = preview.truncated || details.some((line) => !previewLines.includes(line));
339
- const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
340
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
341
- });
342
- pi.registerEntryRenderer("pi-persona-status", (entry, { expanded }, theme) => {
343
- const data = entry.data as CheckInEntry;
344
- const full = sanitizeTerminalText(data.content || "Async activity · routine check-in");
345
- const title = theme.fg("accent", theme.bold("pi-persona · async status"));
346
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
347
- // The entry title already labels this as async status. Spend the compact preview on the
348
- // aggregate and first run row so the operator sees the selected alias and usage label.
349
- const digestLines = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
350
- const preview = compactVisibleText(digestLines.slice(0, 2).join("\n") || full, { maxLines: 2, maxLineChars: 140 });
351
- const hasMore = preview.truncated || digestLines.length > 2;
352
- const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
353
- 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),
354
330
  });
355
331
  const appendNudgeEntry = (content: string): void => {
356
332
  pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
357
333
  };
358
- const appendCheckInEntry = (content: string): void => {
359
- pi.appendEntry("pi-persona-status", { content } satisfies CheckInEntry);
334
+ const appendCheckInEntry = (content: string, kind: "routine" | "stall" = "routine"): void => {
335
+ pi.appendEntry("pi-persona-status", { content, kind } satisfies CheckInEntry);
360
336
  };
361
337
  const appendCommandResult = (
362
338
  label: string,
363
339
  content: string,
364
340
  ok: boolean,
365
- meta: { failureKind?: FailureKind; error?: string } = {},
341
+ meta: { failureKind?: FailureKind; error?: string; stoppedAtGate?: string } = {},
366
342
  ): void => {
367
343
  pi.appendEntry("pi-persona-result", {
368
344
  label,
369
345
  content,
370
346
  ok,
347
+ ...(meta.stoppedAtGate !== undefined ? { stoppedAtGate: meta.stoppedAtGate } : {}),
371
348
  ...(meta.failureKind ? { failureKind: meta.failureKind } : {}),
372
349
  ...(meta.error ? { error: meta.error } : {}),
373
350
  } satisfies CommandResultEntry);
374
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
+ });
375
370
  const enabledSpineRoles = (): SpineLegacySelection => ({
376
371
  supervisor: config.spine === "on",
377
372
  worker: config.spineLegs === "on",
@@ -390,7 +385,37 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
390
385
  return { moved: [], kept: [], warnings: [`data root migration failed: ${message}`] };
391
386
  }
392
387
  })();
388
+ // One persistent settings owner. The root migration must run before importing the two
389
+ // retired stores; fork-guarded children returned before any filesystem access above.
390
+ const settingsFile = join(personaDataDir(), "settings.json");
391
+ let savedSettings: PersonaSettings = { version: 1, preferences: {}, personas: {}, lastPersona: null };
392
+ const settingsWarnings: string[] = [];
393
+ const reportedSettingsWarnings = new Set<string>();
394
+ try {
395
+ const result = migratePersonaSettings(settingsFile, {
396
+ legacyConfigFile: join(personaDataDir(), "config.json"),
397
+ legacyStateFile: config.stateFile ?? join(personaDataDir(), "state.json"),
398
+ });
399
+ savedSettings = result.settings;
400
+ settingsWarnings.push(...result.warnings);
401
+ } catch (error) {
402
+ settingsWarnings.push(`could not load settings: ${error instanceof Error ? error.message : String(error)}`);
403
+ }
404
+ config = resolvePersonaConfig(savedSettings.preferences, process.env);
405
+ function reportSettingsWarning(ctx: ExtensionContext | undefined, message: string): void {
406
+ if (reportedSettingsWarnings.has(message)) return;
407
+ reportedSettingsWarnings.add(message);
408
+ const text = `pi-persona settings: ${compactInlineText(message, { maxChars: 500 })}`;
409
+ try {
410
+ if (ctx?.hasUI) ctx.ui.notify(text, "warning");
411
+ else process.stderr.write(`${text}\n`);
412
+ } catch {
413
+ // A failed UI must not turn recoverable configuration trouble into activation failure.
414
+ try { process.stderr.write(`${text}\n`); } catch { /* Non-fatal diagnostic. */ }
415
+ }
416
+ }
393
417
  const reportDataRootMigration = (ctx: ExtensionContext): void => {
418
+ for (const warning of settingsWarnings) reportSettingsWarning(ctx, warning);
394
419
  if (dataRootMigration.moved.length > 0) {
395
420
  const message = `pi-persona: moved ${dataRootMigration.moved.length} file(s) from the legacy pi-persona storage root into ${personaDataDir()}`;
396
421
  if (ctx.hasUI) ctx.ui.notify(message, "info");
@@ -539,6 +564,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
539
564
  // The unified live tree of every in-flight agent — strategy cores, delegate
540
565
  // sub-agents, dynamic specialists — rendered as one sticky widget above the input.
541
566
  const agentTree = new AgentTree();
567
+ const wakeRows = new WakeRows();
542
568
  // Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
543
569
  // session-scoped and starts only after persona + exocom reconciliation in session_start.
544
570
  let telemetry: TelemetryProducer | undefined;
@@ -561,7 +587,6 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
561
587
  if (node.kind) return node.kind;
562
588
  if (node.id.startsWith("flow:")) return "flow";
563
589
  if (node.id.startsWith("strategy:") || node.id.startsWith("council:")) return "council";
564
- if (node.id.startsWith("delegate:")) return "delegate";
565
590
  return "subagent";
566
591
  }
567
592
  function telemetryStatus(node: AgentNode): TelemetryAgentStatus {
@@ -627,6 +652,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
627
652
  function publishAgentTreeChange(change: AgentTreeChange): void {
628
653
  const producer = telemetry;
629
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;
630
658
  if (change.type === "added") {
631
659
  lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
632
660
  producer.publishAgentAdded(telemetryAgent(change.node));
@@ -637,7 +665,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
637
665
  return;
638
666
  }
639
667
  if (change.type === "removed") {
640
- for (const node of change.nodes) {
668
+ for (const node of change.nodes.filter((n) => !isWakeSource(n))) {
641
669
  lastAgentProjection.delete(node.id);
642
670
  const status = node.status === "running" ? "stopped" : telemetryStatus(node);
643
671
  producer.publish("agent.removed", { id: node.id, status });
@@ -650,10 +678,17 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
650
678
  agentTree.onChange(publishAgentTreeChange);
651
679
  // Delegation nudge: watches the supervisor's OWN tool-result stream and reminds it to hand off
652
680
  // when it grinds heavy work by hand (config.nudge; gated to delegating personas at the hook).
653
- 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
+ });
654
687
  // Persistence nudge: the counterweight — when a delegated leg comes back BLOCKED/UNKNOWN, remind
655
688
  // the supervisor not to bank a premature surrender (same config.nudge gate + hook as above).
656
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;
657
692
  // Every visible run (strategy, council, flow) gets its own tree root — see makeRootIdAllocator.
658
693
  const nextRootId = makeRootIdAllocator();
659
694
  // node id → abort that one agent (so the overlay can STOP a single sub-agent).
@@ -675,7 +710,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
675
710
  fn();
676
711
  agentTree.update(nodeId, { detail: "stopping…" }); // visible at once; the run's own settle path writes the terminal status
677
712
  flushAgentWidgetRender();
678
- 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);
679
716
  steerRegistry.delete(nodeId); // a hard-stopped agent is no longer steerable (mirror the strategy path)
680
717
  return true;
681
718
  }
@@ -684,7 +721,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
684
721
  return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
685
722
  }
686
723
  function waitingAsyncRunIds(): Set<string> {
687
- 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));
688
728
  }
689
729
  const clearSteers = (prefix: string): void => {
690
730
  for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
@@ -707,6 +747,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
707
747
  const WIDGET_RENDER_DELAY_MS = 16;
708
748
  let widgetTimer: ReturnType<typeof setTimeout> | undefined;
709
749
  let widgetRenderPending = false;
750
+ let publishingMonitorOutput = false;
710
751
  let widgetLinesInitialized = false;
711
752
  let lastWidgetLines: readonly string[] | undefined;
712
753
  let widgetStatusInitialized = false;
@@ -717,14 +758,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
717
758
  if (!a || !b || a.length !== b.length) return false;
718
759
  return a.every((line, i) => line === b[i]);
719
760
  }
720
- 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 {
721
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();
722
778
  const empty = nodes.length === 0;
723
779
  const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
724
- const status = empty ? undefined : String(inFlightAgentCount(nodes));
780
+ const status = agentCountStatus(nodes);
725
781
  try {
726
782
  if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
727
- lastCtx.ui.setWidget("persona-agents", lines, { placement: "aboveEditor" });
783
+ lastCtx.ui.setWidget("persona-agents", lines ? compactWidget(lines) : undefined, { placement: "aboveEditor" });
728
784
  lastWidgetLines = lines;
729
785
  widgetLinesInitialized = true;
730
786
  }
@@ -743,8 +799,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
743
799
  }
744
800
  function renderAgentStatus(): void {
745
801
  if (!lastCtx) return;
746
- const nodes = agentTree.snapshot();
747
- const status = nodes.length === 0 ? undefined : String(inFlightAgentCount(nodes));
802
+ const status = agentCountStatus(agentTree.snapshot());
748
803
  try {
749
804
  if (!widgetStatusInitialized || lastWidgetStatus !== status) {
750
805
  lastCtx.ui.setStatus("persona-agents", status);
@@ -806,6 +861,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
806
861
  // elapsed time and the stall badge moving without a progress event, and stops itself when idle.
807
862
  const widgetClock = new LiveClock({ intervalMs: 1_000, isLive: () => agentTree.hasRunning(), onTick: renderAgentWidget });
808
863
  agentTree.onChange((change) => {
864
+ if (publishingMonitorOutput) return;
809
865
  const refreshStatus = widgetStatusMayHaveChanged(change);
810
866
  if (disposed) {
811
867
  // session_shutdown clears the tree after marking this activation disposed. Keep teardown
@@ -849,21 +905,36 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
849
905
  let seedSourceCollisions: string[] = [];
850
906
 
851
907
  // Remembered selection lives in the persona data folder; only user gestures write it.
852
- const stateFile = config.stateFile ?? join(personaDataDir(), "state.json");
853
908
  const readRememberedPersona = (): string | undefined => {
854
909
  if (!config.persist) return undefined;
855
- return readLastPersona(stateFile);
910
+ try {
911
+ savedSettings = readPersonaSettings(settingsFile);
912
+ return savedSettings.lastPersona ?? undefined;
913
+ } catch (error) {
914
+ reportSettingsWarning(lastCtx, `could not read remembered persona: ${error instanceof Error ? error.message : String(error)}`);
915
+ return undefined;
916
+ }
856
917
  };
857
918
  const persist = (name: string | undefined): void => {
858
- if (config.persist) writeLastPersona(stateFile, name);
919
+ if (!config.persist) return;
920
+ try {
921
+ savedSettings = patchPersonaSettings(settingsFile, { lastPersona: name ?? null });
922
+ } catch (error) {
923
+ reportSettingsWarning(lastCtx, `could not save remembered persona: ${error instanceof Error ? error.message : String(error)}`);
924
+ }
859
925
  };
860
926
 
861
- // Per-persona config (model assignments today, open-ended), indexed by persona name.
862
- const configFile = join(personaDataDir(), "config.json");
927
+ // Model preferences share the canonical file with extension preferences and selection.
863
928
  const readConfigStore = (): PersonaConfigStore => {
864
- return readPersonaConfigs(configFile);
929
+ try {
930
+ savedSettings = readPersonaSettings(settingsFile);
931
+ return savedSettings.personas;
932
+ } catch (error) {
933
+ reportSettingsWarning(lastCtx, `could not read model settings: ${error instanceof Error ? error.message : String(error)}`);
934
+ return savedSettings.personas;
935
+ }
865
936
  };
866
- let personaConfigs: PersonaConfigStore = {};
937
+ let personaConfigs: PersonaConfigStore = savedSettings.personas;
867
938
  // A participant is asked for a model at most ONCE per session — dismissal included, so a
868
939
  // closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
869
940
  // roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
@@ -918,6 +989,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
918
989
  definitionWarnings = [...result.warnings, ...contractsLoad.warnings];
919
990
  }
920
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
+
921
997
  // Install the bundled defaults into the user's agent dir — this is the ONLY way personas/agents
922
998
  // become active (they are not a live builtin layer). `/persona seed` pulls in missing defaults,
923
999
  // `/persona restore` force-overwrites them back to the originals. First-run auto-install is
@@ -1069,12 +1145,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1069
1145
  };
1070
1146
  // Async runs outlive the turn that launched them; on completion we surface the result(s) back as
1071
1147
  // ONE coalesced notice so the (idle) supervisor can react — e.g. retry a failure with another model.
1072
- // With delegate now background-by-default (interactive), this is the COMMON delivery path, so the
1073
- // premature-surrender counterweight must ride it too: a settled leg whose report carries a
1074
- // [BLOCKED]/FLAG:UNKNOWN marker gets the persistence note appended (the sync tool_result hook can't
1075
- // 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.
1076
1152
  const completionNotifier = new IdleCoalescingNotifier<AsyncRun>({
1077
1153
  ...idleDelivery,
1154
+ isReady: (run) => tracker.batchSettled(run),
1078
1155
  render: (runs) => {
1079
1156
  const report = renderCompletion(runs, fenceUntrusted, (t) => scanForSurrender(t));
1080
1157
  // Retention is a bound, not a promise: a fan-out wider than it settles more payloads than the
@@ -1107,6 +1184,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1107
1184
  ...idleDelivery,
1108
1185
  render: (entries) => renderTimerFire(entries),
1109
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
+ }
1110
1202
  const timerScheduler = new TimerScheduler({
1111
1203
  now: () => Date.now(),
1112
1204
  setTimer: (fn, ms) => {
@@ -1115,7 +1207,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1115
1207
  return h;
1116
1208
  },
1117
1209
  clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
1118
- 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); },
1119
1217
  });
1120
1218
  const canRunMonitor = () => {
1121
1219
  if (disposed) return false;
@@ -1124,26 +1222,66 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1124
1222
  return active.includes("monitor") && active.includes("bash")
1125
1223
  && (!caps || (canCallTool(caps, "monitor") && canCallTool(caps, "bash")));
1126
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
+ });
1127
1231
  monitors = createMonitorSession({
1232
+ idPrefix: `monitor-${randomUUID().slice(0, 8)}`,
1128
1233
  isIdle: idleDelivery.isIdle,
1129
1234
  deliver: (text) => sendPersonaFollowUp(pi, text),
1130
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
+ },
1131
1262
  });
1132
1263
  const tracker = new AsyncRunTracker({ maxRetained: config.asyncRetain });
1133
1264
  // The premature-surrender counterweight rides the SAME kill switch as the by-hand nudge
1134
- // (PI_PERSONA_NUDGE=off). Both completion surfaces — the background follow-up and `intercom wait` —
1135
- // go through here, so "off" means off wherever a settled leg is collected, not just on the
1136
- // 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.
1137
1267
  const scanForSurrender = (text: string): string | undefined => (config.nudge ? persistenceNudge.scan(text) : undefined);
1138
1268
  // A run id that is neither live nor retained has two very different meanings: it never existed, or
1139
1269
  // its payload was evicted under retention pressure. Only the second deserves an explanation — and a
1140
1270
  // bare "no such run" for a result the supervisor was just TOLD to fetch is exactly the dead end
1141
1271
  // this distinction exists to prevent.
1142
1272
  function missingRunMessage(id: string, display: string | undefined): string {
1143
- 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));
1144
1282
  return (
1145
- `Async run "${display}" settled, but its full payload is gone: more runs settled than the ${tracker.retention}-run retention bound keeps. ` +
1146
- "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 })}.`
1147
1285
  );
1148
1286
  }
1149
1287
  // Turns the periodic peek from a poll into an exception signal: it surfaces a leg only when it
@@ -1157,11 +1295,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1157
1295
  stopRequested.delete(`async:${run.id}`);
1158
1296
  if (disposed) return; // instance torn down — don't notify the next session or re-arm a cancelled timer
1159
1297
  // Immediate human feedback + one semantic completion. Explicit stops are informational,
1160
- // 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.
1161
1299
  announceAsyncRunSettlement(
1162
1300
  run,
1163
1301
  lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
1164
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,
1165
1313
  );
1166
1314
  if (tracker.running().length === 0) stopPeek(); // no live runs → stop the idle peek
1167
1315
  });
@@ -1196,9 +1344,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1196
1344
  });
1197
1345
  });
1198
1346
 
1199
- // Async-run watchdog: the fast PI_PERSONA_PEEK_MS cadence wakes the supervisor only for a newly-stalled
1200
- // leg or unread message. The slower PI_PERSONA_CHECKIN_MS cadence appends a durable operator-only status
1201
- // 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.
1202
1351
  let peekTimer: ReturnType<typeof setInterval> | undefined;
1203
1352
  // Last time progress status was surfaced (a new stall alert or routine card), for the slow cadence.
1204
1353
  let lastPeekAt = 0;
@@ -1209,7 +1358,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1209
1358
  }
1210
1359
  }
1211
1360
  function startPeek(): void {
1212
- // 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
1213
1362
  // and ticks at the faster of the two (a disabled one is Infinity, so it never drives).
1214
1363
  const fastMs = config.peekEveryMs > 0 ? config.peekEveryMs : Number.POSITIVE_INFINITY;
1215
1364
  const checkMs = config.checkInEveryMs > 0 ? config.checkInEveryMs : Number.POSITIVE_INFINITY;
@@ -1223,13 +1372,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1223
1372
  return;
1224
1373
  }
1225
1374
  const now = Date.now();
1226
- const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
1227
- // Routine status is durable operator history, not a model notification: append it on cadence even
1228
- // while a turn is busy or human input is queued. This keeps visibility independent of wake eligibility.
1229
- if (dueCheckIn) {
1230
- lastPeekAt = now;
1375
+ const waitingRuns = waitingAsyncRunIds();
1376
+ const fast = config.peekEveryMs > 0;
1377
+ const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
1378
+ const appendStatus = (content: string, kind: "routine" | "stall"): void => {
1231
1379
  try {
1232
- appendCheckInEntry(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }));
1380
+ appendCheckInEntry(content, kind);
1233
1381
  } catch (error) {
1234
1382
  const detail = compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 120 });
1235
1383
  const warning = `pi-persona: could not append async status entry${detail ? ` (${detail})` : ""}`;
@@ -1241,28 +1389,22 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1241
1389
  try { process.stderr.write(`[pi-persona] ${warning}\n`); } catch { /* diagnostics must not block an actionable wake */ }
1242
1390
  }
1243
1391
  }
1392
+ };
1393
+ // Every new stall episode is durable operator history, including while the model is busy.
1394
+ if (stuck.length > 0) appendStatus(buildCheckIn(stuck, { now, stallMs: STALL_FLAG_MS, reason: "stall", waitingForSupervisor: waitingRuns }), "stall");
1395
+ const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
1396
+ if (dueCheckIn) {
1397
+ lastPeekAt = now;
1398
+ appendStatus(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }), "routine");
1244
1399
  }
1245
- // Poll actionable model wakes only for a free supervisor. A busy/queued session keeps messages
1246
- // unread and leaves stall state untouched for the next tick, rather than stacking sticky follow-ups.
1400
+ // A stall is operator-only (appended above) and never costs a model turn; unread non-ask
1401
+ // messages keep their actionable route.
1247
1402
  if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
1248
- // The watchdog is not a model polling loop: the FAST path wakes on newly-stalled legs or unread
1249
- // progress messages. Completions arrive separately through completionNotifier; `/peek` remains on demand.
1250
- const fast = config.peekEveryMs > 0;
1251
- const waitingRuns = waitingAsyncRunIds();
1252
- const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
1253
- // Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
1254
- // notifier and left for the `intercom inbox` tool — so peek never double-shows them.
1255
1403
  const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
1256
- if (stuck.length === 0 && unread.length === 0) return; // healthy + quiet ⇒ stay silent
1257
- const parts: string[] = [];
1258
- if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
1259
- if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1260
- if (stuck.length > 0) lastPeekAt = now; // message-only wakes do not postpone the routine status cadence
1404
+ if (unread.length === 0) return;
1261
1405
  try {
1262
- sendPersonaFollowUp(pi, `[pi-persona] ${parts.join("\n\n")}`);
1263
- } catch {
1264
- /* ignore */
1265
- }
1406
+ sendPersonaFollowUp(pi, `[pi-persona] 📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1407
+ } catch { /* best-effort wake: the session may be tearing down */ }
1266
1408
  }, tickMs);
1267
1409
  peekTimer.unref?.();
1268
1410
  }
@@ -1360,8 +1502,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1360
1502
  * Every participant a run really spawns — roster members (inline ones included) plus the
1361
1503
  * auxiliary actors its strategy declares — with the model each one resolves to.
1362
1504
  */
1363
- function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
1364
- const persona = controller.activePersona?.name;
1505
+ function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }, persona = controller.activePersona?.name): ModelParticipant[] {
1365
1506
  return collectModelParticipants({
1366
1507
  members: opts.members,
1367
1508
  strategy: opts.strategy,
@@ -1382,15 +1523,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1382
1523
  * Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
1383
1524
  * authenticated model to choose between.
1384
1525
  */
1385
- 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> {
1386
1527
  const scope = modelSelectionScope(ctx);
1387
- if (!scope || !ctx.hasUI) return;
1528
+ if (!scope || !ctx.hasUI || signal.aborted) return;
1388
1529
  const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
1389
1530
  if (missing.length === 0) return;
1390
1531
  const available = configuredModels(ctx);
1391
1532
  if (available.length < 2) return; // automatic setup cannot diversify a single model
1392
- const chosen = await choosePersonaModels(ctx, missing, available, scope);
1393
- 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
+ }
1394
1554
  }
1395
1555
 
1396
1556
  type ModelSelectionScope = { persona: string; isCurrent(): boolean };
@@ -1427,15 +1587,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1427
1587
  }
1428
1588
 
1429
1589
  /** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
1430
- 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> {
1431
1591
  const { persona } = scope;
1432
1592
  const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
1433
1593
  const chosen: Record<string, string> = {};
1594
+ const addedPromptKeys = new Set<string>();
1434
1595
  try {
1435
1596
  for (const p of participants) {
1597
+ if (signal?.aborted) return;
1436
1598
  // Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
1437
- // for this session, exactly like an answer is not asked twice.
1438
- 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);
1439
1604
  // Name the core's verticalization in the picker too: choosing a model for "the
1440
1605
  // Conservatore" is a different judgement than choosing one for a bare "balthasar".
1441
1606
  // An auxiliary actor names the param that declared it ("arbiter judge").
@@ -1448,14 +1613,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1448
1613
  // In the TUI: a searchable picker (type to filter) whose viewport follows the
1449
1614
  // selection — the built-in select can't scroll a hundreds-long provider list
1450
1615
  // usefully. Outside the TUI (RPC), fall back to the built-in select.
1451
- const pick =
1452
- ctx.mode === "tui"
1616
+ let pick: string | undefined;
1617
+ let cancelPicker: (() => void) | undefined;
1618
+ try {
1619
+ pick = ctx.mode === "tui"
1453
1620
  ? await ctx.ui.custom<string | undefined>(
1454
- (tui, theme, _kb, done) =>
1455
- 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
+ },
1456
1632
  { overlay: true },
1457
- )
1458
- : 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;
1459
1639
  if (!scope.isCurrent()) {
1460
1640
  ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1461
1641
  return;
@@ -1464,7 +1644,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1464
1644
  }
1465
1645
  } catch {
1466
1646
  /* dismissed / no UI → fall back to the default model */
1647
+ } finally {
1648
+ if (signal?.aborted) for (const key of addedPromptKeys) promptedParticipants.delete(key);
1467
1649
  }
1650
+ if (signal?.aborted) return;
1468
1651
  if (!scope.isCurrent()) {
1469
1652
  ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1470
1653
  return;
@@ -1478,7 +1661,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1478
1661
  // future session with no hint that the assignment never landed.
1479
1662
  personaConfigs = withPersonaModels(personaConfigs, persona, chosen);
1480
1663
  try {
1481
- writePersonaConfigs(configFile, personaConfigs);
1664
+ savedSettings = patchPersonaSettings(settingsFile, { models: { [persona]: chosen } });
1665
+ personaConfigs = savedSettings.personas;
1482
1666
  } catch (err) {
1483
1667
  ctx.ui.notify(
1484
1668
  `pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
@@ -1599,7 +1783,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1599
1783
  // The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
1600
1784
  // `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
1601
1785
  // cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
1602
- 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) {
1603
1787
  return {
1604
1788
  onAgentStart: (agent: string, abort: () => void, key?: string) => {
1605
1789
  stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
@@ -1638,6 +1822,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1638
1822
  agentTree.update(id, progressPatch(p, now));
1639
1823
  // Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
1640
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
1641
1826
  },
1642
1827
  };
1643
1828
  }
@@ -1647,6 +1832,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1647
1832
  return caps ? { canSpawn: (agent) => canDelegateTo(caps, agent) } : {};
1648
1833
  }
1649
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
+
1650
1852
  // Run a persona strategy with the unified tree wired in: assign models on first run,
1651
1853
  // seed the roster (cores show by name at once), flip ⏳ → ✓/✗ live, clear when done.
1652
1854
  async function runStrategyVisible(
@@ -1654,34 +1856,39 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1654
1856
  orch: OrchestrationGrammar,
1655
1857
  task: string,
1656
1858
  idPrefix: string,
1657
- signal?: AbortSignal,
1859
+ tracked?: TrackedRun,
1658
1860
  ) {
1659
1861
  const label = resolveStrategyName(orch) ?? "strategy";
1660
1862
  const roster = resolveOrchestrationMembers(orch, teams);
1661
1863
  const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
1662
- await ensurePersonaModels(ctx, participants);
1663
1864
  const participantsByNode = participantByNode(participants);
1664
- const rootId = nextRootId(`${idPrefix}:${label}`);
1665
- agentTree.add({ id: rootId, label, status: "running", kind: "council" });
1666
- // Seed the whole roster at once (cores show by name immediately); "queued" until the
1667
- // engine actually starts each one — an honest view under the concurrency limit.
1668
- const seedKeys = rosterNodeKeys(roster);
1669
- roster.forEach((m, i) => {
1670
- const a = rosterSpec(m).agent;
1671
- const key = seedKeys[i] ?? a;
1672
- const participant = participantForNode(participantsByNode, key);
1673
- const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1674
- 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" });
1675
- });
1865
+ const { rootId, signal, live } = visibleRoot(`${idPrefix}:${label}`, tracked);
1866
+ agentTree.add({ id: rootId, label, status: "running", kind: "council", ...live });
1676
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
+ });
1677
1884
  // The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
1678
1885
  // cooperatively between rounds, so an aborted run stops convening instead of running every
1679
1886
  // remaining round against an already-cancelled engine (docs/STRATEGIES.md).
1680
- 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) });
1681
- 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" });
1682
1889
  return result ?? undefined;
1683
1890
  } catch (error) {
1684
- agentTree.update(rootId, { status: signal?.aborted ? "stopped" : "failed" });
1891
+ agentTree.update(rootId, { status: signal.aborted ? "stopped" : "failed" });
1685
1892
  throw error;
1686
1893
  } finally {
1687
1894
  clearStops(rootId);
@@ -1720,12 +1927,45 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1720
1927
  if (found === undefined) return undefined;
1721
1928
  return parseFlow(found);
1722
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
+ }
1723
1963
 
1724
1964
  // Run a flow's DAG with the unified tree (phases as nodes, cores beneath) + journaled
1725
- // 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
1726
1966
  // journal is cleared on a fully-successful run.
1727
- async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string, signal?: AbortSignal): Promise<FlowOutcome> {
1728
- 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);
1729
1969
  const journalDir = join(personaDataDir(), "flows");
1730
1970
  try {
1731
1971
  mkdirSync(journalDir, { recursive: true });
@@ -1751,17 +1991,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1751
1991
  return [phase.id, { members, participants }] as const;
1752
1992
  }),
1753
1993
  );
1754
- await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []));
1755
-
1756
- const flowRoot = nextRootId(`flow:${spec.name}`);
1757
- agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
1758
- for (const p of spec.phases) {
1759
- const pid = `${flowRoot}/${p.id}`;
1760
- const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
1761
- if (resume[p.id]) node.detail = "resumed";
1762
- agentTree.add(node);
1763
- }
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 });
1764
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
+ }
1765
2008
  // Yield so a concurrent flow can seed its own tree before we fail-closed on refs —
1766
2009
  // the check still runs BEFORE any phase/engine work.
1767
2010
  await Promise.resolve();
@@ -1784,7 +2027,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1784
2027
  const outcome = await runFlow(spec, baseTask, {
1785
2028
  hash,
1786
2029
  resume,
1787
- ...(signal ? { signal } : {}),
2030
+ signal,
1788
2031
  journal: journalWriter(journalPath),
1789
2032
  onPhase: (id, status, result) => {
1790
2033
  const patch: { status: AgentNodeStatus; output?: string; detail?: string } = {
@@ -1810,12 +2053,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1810
2053
  const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
1811
2054
  if (phase.roster) orch.roster = phase.roster;
1812
2055
  const r = await runPersonaStrategy(orch, task, {
1813
- engine: buildEngine(signal, undefined, { providerFallback: false }),
2056
+ engine: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }),
1814
2057
  teams,
1815
2058
  limits: RUN_LIMITS,
1816
2059
  ...sessionModelDep(ctx),
1817
- ...(signal ? { signal } : {}), // cooperative per-round abort inside the phase's strategy, not just the engine
1818
- ...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),
1819
2062
  ...strategySpawnGate(),
1820
2063
  ...modelFallbackDep(ctx),
1821
2064
  });
@@ -1826,26 +2069,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1826
2069
  // boundary, never an informational prompt. Approval is journaled for resume.
1827
2070
  approveGate: async (phase, result) => {
1828
2071
  if (!ctx.hasUI) return false;
1829
- 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);
1830
2078
  try {
1831
- 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
+ );
1832
2084
  return pick === "Approve";
1833
2085
  } catch {
1834
2086
  return false;
2087
+ } finally {
2088
+ tracked?.onAwaiting(undefined);
2089
+ agentTree.update(flowRoot, { detail: "", lastAdvanceAt: Date.now() });
1835
2090
  }
1836
2091
  },
1837
2092
  });
1838
- if (outcome.ok) {
1839
- try {
1840
- rmSync(journalPath, { force: true }); // clean journal once the whole flow succeeds
1841
- } catch {
1842
- /* ignore */
1843
- }
1844
- }
1845
- 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 });
1846
2097
  return outcome;
1847
2098
  } catch (error) {
1848
- agentTree.update(flowRoot, { status: signal?.aborted ? "stopped" : "failed" });
2099
+ agentTree.update(flowRoot, { status: signal.aborted ? "stopped" : "failed" });
1849
2100
  throw error;
1850
2101
  } finally {
1851
2102
  clearStops(flowRoot);
@@ -1854,15 +2105,53 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1854
2105
  }
1855
2106
  }
1856
2107
 
1857
- // Search/list available models (provider/id), session provider first (★).
1858
- 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 } {
1859
2140
  const session = ctx.model?.provider;
1860
2141
  const isSession = (ref: string): boolean => session !== undefined && ref.startsWith(`${session}/`);
1861
- const all = configuredModels(ctx).map((m) => `${m.provider}/${m.id}`);
1862
- 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}`);
1863
2148
  const cap = 40;
1864
- const lines = filtered.slice(0, cap).map((r) => `${isSession(r) ? "★ " : " "}${r}`);
1865
- 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 };
1866
2155
  }
1867
2156
 
1868
2157
  // The per-turn delegation brief (core/brief.ts): live roster + standing hand-off default,
@@ -1870,11 +2159,19 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1870
2159
  // persona line has no force, and regenerated from the live registry so it cannot desync.
1871
2160
  // Capability-aware: a persona that denies `delegate` gets none, and the agent list is
1872
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
+
1873
2168
  function delegationBrief(ctx: ExtensionContext): string | undefined {
1874
2169
  const caps = controller.capabilities;
1875
2170
  if (caps && !canFanOut(caps)) return undefined;
1876
- const targets = caps ? agents.filter((a) => canDelegateTo(caps, a.name)) : agents;
2171
+ const targets = delegateTargets();
1877
2172
  const delegation = controller.activePersona?.delegation;
2173
+ const canListModels = canDiscoverModels();
2174
+ const candidates = canListModels && ctx.model ? currentModelCatalog(ctx).candidates : undefined;
1878
2175
  const teamAgents: Record<string, string[]> = {};
1879
2176
  for (const [name, members] of Object.entries(teams)) teamAgents[name] = members.map((m) => rosterSpec(m).agent);
1880
2177
  return buildDelegationBrief({
@@ -1887,11 +2184,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1887
2184
  ...(a.isolation ? { isolation: a.isolation } : {}),
1888
2185
  })),
1889
2186
  teams: teamAgents,
1890
- 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 } : {}),
1891
2193
  standing: controller.activePersona !== undefined,
1892
- // Interactive sessions delegate in the background by default; headless (`pi -p`)
1893
- // stays sync (the single turn must carry the result) — mirror that in the copy.
1894
- 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,
1895
2197
  ...(delegation?.requireBrief !== undefined ? { requireBrief: delegation.requireBrief } : {}),
1896
2198
  ...(delegation?.outputContract ? { outputContract: delegation.outputContract } : {}),
1897
2199
  ...(delegation?.requireDisjointWrites !== undefined ? { requireDisjointWrites: delegation.requireDisjointWrites } : {}),
@@ -1915,6 +2217,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1915
2217
  function doctorReport(): string {
1916
2218
  const lines: string[] = [];
1917
2219
  lines.push(`pi-persona — active: ${controller.activePersona?.label ?? "none"}`);
2220
+ lines.push(`settings: ${settingsFile} (/persona-settings; preferences: next session or Pi /reload)`);
1918
2221
  lines.push(`engine backend: ${config.engine === "child" ? "child-process" : "in-process"}`);
1919
2222
  const piVersion = installedPiVersion();
1920
2223
  lines.push(`pi: ${piVersion ?? "unknown"} (requires ≥ ${MIN_PI_VERSION})${piVersion && !satisfiesFloor(piVersion, MIN_PI_VERSION) ? " — BELOW FLOOR" : ""}`);
@@ -1942,7 +2245,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1942
2245
  lines.push(`data root migration: moved ${dataRootMigration.moved.length}, kept ${dataRootMigration.kept.length} (legacy pi-persona root folded into ${personaDataDir()})`);
1943
2246
  }
1944
2247
  for (const warning of dataRootMigration.warnings) lines.push(warning);
1945
- 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}`);
1946
2249
  lines.push(`agents (${agents.length}): ${agents.map((a) => a.name).join(", ") || "—"}`);
1947
2250
  if (definitionCollisions.length > 0) {
1948
2251
  lines.push(`definition collisions (${definitionCollisions.length}, omitted): ${definitionCollisions.map((collision) => collision.name).join(", ")}`);
@@ -1997,7 +2300,14 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1997
2300
  get lastCtx() { return lastCtx; },
1998
2301
  set lastCtx(value) { lastCtx = value; },
1999
2302
  get disposed() { return disposed; },
2000
- 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
+ },
2001
2311
  get orchestrating() { return orchestrating; },
2002
2312
  set orchestrating(value) { orchestrating = value; },
2003
2313
  get processingDeferredOrchestration() { return processingDeferredOrchestration; },
@@ -2017,6 +2327,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2017
2327
  telemetryAgent,
2018
2328
  delegationNudge,
2019
2329
  persistenceNudge,
2330
+ pollGuard,
2331
+ tracker,
2020
2332
  appendNudgeEntry,
2021
2333
  completionNotifier,
2022
2334
  intercomNotifier,
@@ -2062,6 +2374,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2062
2374
  };
2063
2375
  installHooks(pi, hookHost, exocom);
2064
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
+
2065
2391
  registerDelegateTool(pi, {
2066
2392
  get lastCtx() { return lastCtx; },
2067
2393
  set lastCtx(value) { lastCtx = value; },
@@ -2070,21 +2396,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2070
2396
  contractNames: () => installedContractNames(contractDefs),
2071
2397
  buildEngine,
2072
2398
  agentTree,
2073
- nextRootId,
2074
2399
  tracker,
2400
+ runs: backgroundRuns,
2401
+ launchPool,
2075
2402
  config: { ledgerV2: config.ledgerV2 },
2076
2403
  RUN_LIMITS,
2077
2404
  publishAgentTool,
2078
2405
  stopRegistry,
2079
2406
  steerRegistry,
2080
- stopRequested,
2081
- ensurePersonaModels,
2082
- clearStops,
2083
- clearSteers,
2084
2407
  drainBusBlock,
2085
2408
  startPeek,
2086
- childUsage,
2087
- publishPersonaCost,
2088
2409
  });
2089
2410
 
2090
2411
  // ── f8 cycle ────────────────────────────────────────────────────────────────
@@ -2092,7 +2413,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2092
2413
  description: "Cycle persona (pi-persona)",
2093
2414
  handler: async (ctx: ExtensionContext) => {
2094
2415
  lastCtx = ctx;
2095
- if (personas.length === 0) return;
2416
+ if (personas.length === 0) {
2417
+ if (ctx.hasUI) ctx.ui.notify(NO_PERSONAS_HINT, "info");
2418
+ return;
2419
+ }
2096
2420
  const current = controller.activePersona;
2097
2421
  const idx = current ? personas.findIndex((p) => p.name === current.name) : -1;
2098
2422
  const next = idx + 1;
@@ -2130,14 +2454,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2130
2454
  SUPERVISOR,
2131
2455
  STALL_FLAG_MS,
2132
2456
  waitingRunIds: waitingAsyncRunIds,
2457
+ pollGuard,
2133
2458
  missingRunMessage,
2134
2459
  stopAgent,
2135
2460
  steerAgent,
2136
2461
  steerRegistry,
2137
2462
  stopRequested,
2138
- drainBusBlock,
2139
2463
  scanForSurrender,
2140
- get disposed() { return disposed; },
2141
2464
  childUsage,
2142
2465
  publishPersonaCost,
2143
2466
  });
@@ -2152,6 +2475,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2152
2475
  set lastCtx(value) { lastCtx = value; },
2153
2476
  canRun: canRunMonitor,
2154
2477
  monitors,
2478
+ archive: monitorArchive,
2155
2479
  });
2156
2480
 
2157
2481
  registerCouncilTool(pi, {
@@ -2160,9 +2484,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2160
2484
  controller,
2161
2485
  get personas() { return personas; },
2162
2486
  runStrategyVisible,
2487
+ runs: backgroundRuns,
2163
2488
  drainBusBlock,
2164
- childUsage,
2165
- publishPersonaCost,
2166
2489
  });
2167
2490
 
2168
2491
  registerFlowTool(pi, {
@@ -2171,8 +2494,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2171
2494
  loadFlow,
2172
2495
  listFlows,
2173
2496
  runFlowVisible,
2174
- childUsage,
2175
- publishPersonaCost,
2497
+ runs: backgroundRuns,
2176
2498
  });
2177
2499
 
2178
2500
  // ── f9: navigable agent overlay ──────────────────────────────────────────────
@@ -2191,6 +2513,67 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2191
2513
  },
2192
2514
  });
2193
2515
 
2516
+ // The dedicated settings surface never replaces Pi's /settings and never calls /reload.
2517
+ registerPersonaSettings(pi, {
2518
+ file: settingsFile,
2519
+ companions: () => collectCompanionSettings(pi.events),
2520
+ activeConfig: config,
2521
+ env: process.env,
2522
+ read: () => {
2523
+ savedSettings = readPersonaSettings(settingsFile);
2524
+ personaConfigs = savedSettings.personas;
2525
+ return savedSettings;
2526
+ },
2527
+ write: (patch) => {
2528
+ savedSettings = patchPersonaSettings(settingsFile, patch);
2529
+ personaConfigs = savedSettings.personas;
2530
+ },
2531
+ isCurrent: (ctx) => !disposed && !ctx.signal?.aborted &&
2532
+ (ctx.sessionManager?.getSessionId?.() === undefined ||
2533
+ lastCtx?.sessionManager?.getSessionId?.() === ctx.sessionManager.getSessionId()),
2534
+ diagnostics: doctorReport,
2535
+ models: async (ctx) => {
2536
+ lastCtx = ctx;
2537
+ personaConfigs = readConfigStore();
2538
+ const session = ctx.sessionManager?.getSessionId?.();
2539
+ await openPersonaModelSettings(ctx, {
2540
+ owners: () => personas.map((persona) => ({ name: persona.name, label: persona.label })),
2541
+ activeOwner: () => controller.activePersona?.name,
2542
+ profiles: (owner) => {
2543
+ const persona = personas.find((p) => p.name === owner);
2544
+ return [
2545
+ ...(persona?.orchestration?.strategy ? [{ name: "@orchestration", label: "Automatic orchestration" }] : []),
2546
+ ...personas.filter((p) => p.name !== owner && (p.council?.strategy || p.council?.members || p.council?.membersProblem))
2547
+ .map((p) => ({ name: p.name, label: `Council: ${p.label}` })),
2548
+ ];
2549
+ },
2550
+ participants: (owner, profile) => {
2551
+ const persona = personas.find((p) => p.name === owner);
2552
+ if (!persona) throw new Error(`persona "${owner}" is no longer installed`);
2553
+ const orchestrationStrategy = persona.orchestration?.strategy;
2554
+ if (profile === "@orchestration" && orchestrationStrategy && persona.orchestration) {
2555
+ const orchestration = persona.orchestration;
2556
+ const panel = resolveOrchestrationMembers(orchestration, teams);
2557
+ return runParticipants(ctx, { strategy: orchestrationStrategy, members: panel,
2558
+ ...(orchestration.params ? { params: orchestration.params } : {}) }, owner);
2559
+ }
2560
+ const resolved = resolveCouncilInvocation(personas, persona, profile ? { persona: profile } : {});
2561
+ if (!resolved.ok) throw new Error(resolved.error);
2562
+ const { strategy, roster, members, params } = resolved.value;
2563
+ const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
2564
+ return runParticipants(ctx, { strategy, members: panel, params }, owner);
2565
+ },
2566
+ saved: (owner) => personaModels(personaConfigs, owner),
2567
+ write: (owner, models) => {
2568
+ savedSettings = patchPersonaSettings(settingsFile, { models: { [owner]: models } });
2569
+ personaConfigs = savedSettings.personas;
2570
+ },
2571
+ isCurrent: () => !disposed && (session === undefined || lastCtx?.sessionManager?.getSessionId?.() === session),
2572
+ refreshCatalog: (signal) => refreshModelCatalog(ctx, signal),
2573
+ });
2574
+ },
2575
+ });
2576
+
2194
2577
  // ── /persona ─────────────────────────────────────────────────────────────────
2195
2578
  pi.registerCommand("persona", {
2196
2579
  description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
@@ -2219,7 +2602,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2219
2602
  const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
2220
2603
  const deadline = AbortSignal.timeout(20_000);
2221
2604
  const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
2222
- const result = await ctx.modelRegistry.refresh?.({ signal });
2605
+ const result = await refreshModelCatalog(ctx, signal);
2223
2606
  if (!scope.isCurrent()) {
2224
2607
  ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
2225
2608
  return;
@@ -2291,14 +2674,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2291
2674
  return;
2292
2675
  }
2293
2676
  if (arg === "" || arg === "list") {
2294
- // Empty by design on a fresh install: personas load only from the user dir, which the
2295
- // user populates on purpose. Point them at the install gesture rather than leaving a
2296
- // bare "(none)" that reads like something is broken.
2297
2677
  if (personas.length === 0) {
2298
- ctx.ui.notify(
2299
- "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).",
2300
- "info",
2301
- );
2678
+ ctx.ui.notify(NO_PERSONAS_HINT, "info");
2302
2679
  return;
2303
2680
  }
2304
2681
  const lines = personas.map(
@@ -2380,16 +2757,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2380
2757
 
2381
2758
  // ── /models + a `models` tool — discover/search model ids ─────────────────────
2382
2759
  pi.registerCommand("models", {
2383
- description: "List or search available models (provider/id): /models [query]",
2760
+ description: "Show model candidates or search the full catalog (provider/id): /models [query]",
2384
2761
  handler: async (args, ctx) => {
2385
2762
  lastCtx = ctx;
2386
- const { lines, total, capped } = searchModels(ctx, args);
2763
+ const { lines, total, capped, shortlist } = searchModels(ctx, args);
2387
2764
  if (lines.length === 0) {
2388
2765
  ctx.ui.notify(`models: no match for "${args.trim()}"`, "warning");
2389
2766
  return;
2390
2767
  }
2391
- const head = `models (${total}${capped ? ", showing 40" : ""})${ctx.model ? ` · session ★ ${ctx.model.provider}/${ctx.model.id}` : ""}`;
2392
- 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");
2393
2770
  },
2394
2771
  });
2395
2772
 
@@ -2437,11 +2814,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2437
2814
  return;
2438
2815
  }
2439
2816
  try {
2440
- const outcome = await runFlowVisible(ctx, parsed.flow, task);
2441
- appendCommandResult(`flow ${orch.flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
2442
- ...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
2443
- ...(outcome.error ? { error: outcome.error } : {}),
2444
- });
2817
+ appendFlowResult(orch.flow, await runFlowVisible(ctx, parsed.flow, task));
2445
2818
  } catch (err) {
2446
2819
  ctx.ui.notify(`orchestrate failed: ${err instanceof Error ? err.message : String(err)}`, "error");
2447
2820
  }
@@ -2460,7 +2833,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2460
2833
  }
2461
2834
  try {
2462
2835
  const result = await runStrategyVisible(ctx, orch, task, "orchestrate");
2463
- appendCommandResult(
2836
+ appendOperatorResult(
2464
2837
  `orchestrate ${strategyName}`,
2465
2838
  result?.output || "(no output)",
2466
2839
  result?.ok ?? false,
@@ -2482,9 +2855,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2482
2855
  lastCtx = ctx;
2483
2856
  const trimmed = args.trim();
2484
2857
  if (!trimmed || trimmed === "list") {
2485
- const flows = listFlows(ctx.cwd);
2858
+ const flows = flowCatalog(ctx.cwd);
2486
2859
  ctx.ui.notify(
2487
- 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",
2488
2867
  "info",
2489
2868
  );
2490
2869
  return;
@@ -2506,11 +2885,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2506
2885
  return;
2507
2886
  }
2508
2887
  try {
2509
- const outcome = await runFlowVisible(ctx, parsed.flow, task);
2510
- appendCommandResult(`flow ${name}`, outcome.output || "(flow produced no output)", outcome.ok, {
2511
- ...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
2512
- ...(outcome.error ? { error: outcome.error } : {}),
2513
- });
2888
+ appendFlowResult(name, await runFlowVisible(ctx, parsed.flow, task));
2514
2889
  } catch (err) {
2515
2890
  ctx.ui.notify(`flow failed: ${err instanceof Error ? err.message : String(err)}`, "error");
2516
2891
  }