@aeondave/pi-persona 1.15.2 → 1.16.0

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 (44) hide show
  1. package/AGENTS.md +30 -6
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +27 -8
  4. package/SECURITY.md +42 -0
  5. package/docs/ARCHITECTURE.md +52 -7
  6. package/docs/EXPERIENCE.md +21 -5
  7. package/docs/README.md +2 -0
  8. package/docs/REFERENCE.md +84 -14
  9. package/docs/STRATEGIES.md +58 -31
  10. package/docs/TELEMETRY.md +8 -0
  11. package/package.json +11 -9
  12. package/src/bus/broker/paths.ts +3 -2
  13. package/src/core/brief.ts +1 -1
  14. package/src/core/nudge.ts +3 -4
  15. package/src/core/pi-compat.ts +3 -3
  16. package/src/engine/adapter.ts +11 -6
  17. package/src/engine/async.ts +12 -6
  18. package/src/engine/inproc.ts +14 -6
  19. package/src/extension/engine.ts +25 -4
  20. package/src/extension/hooks.ts +68 -21
  21. package/src/extension.ts +209 -49
  22. package/src/orchestration/model-retry.ts +32 -61
  23. package/src/orchestration/params.ts +27 -1
  24. package/src/orchestration/roster.ts +80 -3
  25. package/src/orchestration/sdk.ts +150 -36
  26. package/src/orchestration/strategies/compete.ts +14 -8
  27. package/src/orchestration/strategies/council-rounds.ts +8 -1
  28. package/src/orchestration/strategies/critic-loop.ts +25 -11
  29. package/src/orchestration/strategies/judge.ts +18 -9
  30. package/src/orchestration/strategies/magi.ts +42 -93
  31. package/src/orchestration/strategies/map.ts +41 -19
  32. package/src/orchestration/strategies/pipeline.ts +2 -2
  33. package/src/orchestration/strategies/synthesize.ts +18 -11
  34. package/src/orchestration/types.ts +15 -0
  35. package/src/persona/model-participants.ts +240 -0
  36. package/src/persona/orchestrate.ts +44 -2
  37. package/src/persona/persona.ts +86 -14
  38. package/src/telemetry/contract.ts +1 -1
  39. package/src/telemetry/producer.ts +241 -8
  40. package/src/tools/council.ts +36 -8
  41. package/src/tools/delegate-tool.ts +162 -24
  42. package/src/tools/intercom-tool.ts +119 -32
  43. package/src/ui/agent-overlay.ts +15 -6
  44. package/src/ui/agent-tree.ts +39 -2
@@ -10,7 +10,7 @@ import { buildExocomBrief, RUNTIME_CONTEXT_GUIDANCE } from "../core/brief.ts";
10
10
  import { canCallTool, canDelegateTo, canFanOut } from "../core/capabilities.ts";
11
11
  import type { PiPersonaConfig } from "../core/config.ts";
12
12
  import { fenceUntrusted } from "../core/fence.ts";
13
- import type { DelegationNudge, PersistenceNudge } from "../core/nudge.ts";
13
+ import { HANDOFF_TOOL_NAMES, type DelegationNudge, type PersistenceNudge } from "../core/nudge.ts";
14
14
  import { buildSessionAnchor } from "../core/time.ts";
15
15
  import { readClockSnapshot } from "./clock.ts";
16
16
  import { type AsyncRun, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
@@ -34,6 +34,7 @@ import type { PersonaConfigStore } from "../persona/config-store.ts";
34
34
  import { TELEMETRY_EVENT_NAME, type TelemetryEvent } from "../telemetry/contract.ts";
35
35
  import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
36
36
  import type { AgentTree, AgentNode } from "../ui/agent-tree.ts";
37
+ import { compactInlineText } from "../ui/presentation.ts";
37
38
  import type { FailureKind } from "../orchestration/types.ts";
38
39
  import type { ExocomInstall } from "../exocom/install.ts";
39
40
  import type { SessionIdentity } from "./identity.ts";
@@ -113,8 +114,19 @@ export interface HookHost {
113
114
  }
114
115
 
115
116
  export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstall): void {
117
+ // Only parent ids are retained, never child output. Propagate through wrappers only when they
118
+ // actually relay a surrender marker; the outer model-visible result owns the note and card.
119
+ const pendingSurrenderRelays = new Set<string>();
120
+ const rememberSurrenderRelay = (id: string): void => {
121
+ if (pendingSurrenderRelays.size >= 256) {
122
+ const oldest = pendingSurrenderRelays.values().next().value;
123
+ if (oldest !== undefined) pendingSurrenderRelays.delete(oldest);
124
+ }
125
+ pendingSurrenderRelays.add(id);
126
+ };
116
127
  // ── lifecycle ─────────────────────────────────────────────────────────────
117
128
  pi.on("session_start", async (_event, ctx) => {
129
+ pendingSurrenderRelays.clear();
118
130
  h.lastCtx = ctx;
119
131
  h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
120
132
  h.childUsage.reset();
@@ -174,29 +186,48 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
174
186
  const sessionId = sessionManager?.getSessionId?.();
175
187
  if (sessionId) {
176
188
  const eventBus = (pi as unknown as { events?: { emit?: (name: string, event: TelemetryEvent) => void } }).events;
177
- h.telemetry = new TelemetryProducer({
178
- agentDir: h.userAgentDir(),
179
- cwd: ctx.cwd,
180
- sessionId,
181
- emit: (event) => eventBus?.emit?.(TELEMETRY_EVENT_NAME, event),
182
- heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
183
- ...(process.env.PI_PERSONA_DEBUG
184
- ? { onError: (error: unknown) => { process.stderr.write(`[pi-persona] telemetry: ${error instanceof Error ? error.message : String(error)}\n`); } }
185
- : {}),
186
- });
187
- h.telemetry.start(exocom.currentTelemetryInstance(ctx));
188
- // Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
189
- // published — a session start that follows no teardown carries no stale projection.
190
- h.lastAgentProjection.clear();
191
- for (const node of h.agentTree.snapshot()) {
192
- h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
193
- h.telemetry.publishAgentAdded(h.telemetryAgent(node));
189
+ try {
190
+ h.telemetry = new TelemetryProducer({
191
+ agentDir: h.userAgentDir(),
192
+ cwd: ctx.cwd,
193
+ sessionId,
194
+ emit: (event) => eventBus?.emit?.(TELEMETRY_EVENT_NAME, event),
195
+ heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
196
+ onError: (error: unknown) => {
197
+ // Retention and writer failures must be observable during normal operation too.
198
+ // A broken UI/logger must never turn best-effort telemetry into a session failure.
199
+ try {
200
+ const message = `pi-persona: telemetry: ${compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 400 })}`;
201
+ const current = h.lastCtx ?? ctx;
202
+ if (current.hasUI) current.ui.notify(message, "warning");
203
+ else process.stderr.write(`${message}\n`);
204
+ } catch { /* diagnostics are non-fatal */ }
205
+ },
206
+ });
207
+ h.telemetry.start(exocom.currentTelemetryInstance(ctx));
208
+ } catch (error) {
209
+ h.telemetry = undefined;
210
+ const message = "pi-persona: telemetry unavailable (safe session admission failed)";
211
+ try {
212
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
213
+ else process.stderr.write(`${message}\n`);
214
+ } catch { /* admission diagnostics are non-fatal too */ }
215
+ }
216
+ if (h.telemetry) {
217
+ // Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
218
+ // published — a session start that follows no teardown carries no stale projection.
219
+ h.lastAgentProjection.clear();
220
+ for (const node of h.agentTree.snapshot()) {
221
+ h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
222
+ h.telemetry.publishAgentAdded(h.telemetryAgent(node));
223
+ }
224
+ exocom.publishTelemetryPeers();
194
225
  }
195
- exocom.publishTelemetryPeers();
196
226
  }
197
227
  });
198
228
 
199
229
  pi.on("session_shutdown", async (event, ctx) => {
230
+ pendingSurrenderRelays.clear();
200
231
  h.lastCtx = ctx;
201
232
  h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
202
233
  h.deferredOrchestrations.length = 0;
@@ -246,6 +277,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
246
277
  // idle window first; its replay starts the next run and the remaining FIFO entries follow one per
247
278
  // settlement. With no deferred input, flush ordinary wakes immediately.
248
279
  pi.on("agent_settled", async () => {
280
+ pendingSurrenderRelays.clear();
249
281
  if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
250
282
  // Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
251
283
  // after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
@@ -625,16 +657,31 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
625
657
  if (!h.config.nudge) return errorPatch;
626
658
  // Only a supervisor that CAN delegate is nudged to — a persona without the tool can't act on it.
627
659
  if (!h.controller.capabilities?.tools.has("delegate")) return errorPatch;
660
+ const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
661
+ const relayed = pendingSurrenderRelays.delete(event.toolCallId);
662
+ const surrender = relayed ? h.persistenceNudge.scan(text) : h.persistenceNudge.observe(event.toolName, text);
663
+ // A NESTED call (a codemode script's `ctx.executeTool`) is a real execution — the gate, the
664
+ // telemetry above and the error patch all already ran — but its content is not a model-facing
665
+ // tool result. The caller chooses what to print or discard; only that outer output is charged
666
+ // to the context-burn run. Appending a note here would modify programmatic data and could
667
+ // create an invisible reminder. `parentToolCallId` is the host's explicit marker; `<parent>/<n>`
668
+ // id shape is never inferred from the id string.
669
+ if (typeof event.parentToolCallId === "string" && event.parentToolCallId.length > 0) {
670
+ if (surrender) rememberSurrenderRelay(event.parentToolCallId);
671
+ // A hand-off that actually landed ends the run even when a script issued it, so the reset is
672
+ // NOT dropped here. A FAILED one does not reset, and its repair note would be invisible:
673
+ // the next model-visible result is what reports the streak.
674
+ if (!errorPatch && event.isError !== true && HANDOFF_TOOL_NAMES.has(event.toolName)) h.delegationNudge.reset();
675
+ return errorPatch;
676
+ }
628
677
  const notes: string[] = [];
629
678
  // Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
630
679
  // tools (delegate/council reset the run). `size` classifies substantive vs glue + fat dump.
631
- const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
632
680
  const size = text.length;
633
681
  const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
634
682
  if (sweepNote) notes.push(sweepNote);
635
683
  // Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council
636
684
  // results only; because delegate/council reset the run the two never fire on one event).
637
- const surrender = h.persistenceNudge.observe(event.toolName, text);
638
685
  if (surrender) notes.push(surrender);
639
686
  if (notes.length === 0) return errorPatch;
640
687
  const joined = notes.join("\n\n");
package/src/extension.ts CHANGED
@@ -96,13 +96,18 @@ import { type FlowSpec, flowHash, parseFlow, verifyFlowRefs } from "./orchestrat
96
96
  import { journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
97
97
  import { runFlow } from "./orchestration/flow-run.ts";
98
98
  import type { FlowOutcome } from "./orchestration/flow-run.ts";
99
- import { type RosterMember, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
99
+ import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
100
100
  import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
101
101
  import { knownParams, strategyNames } from "./orchestration/strategy.ts";
102
102
  import { compactMemberResult } from "./orchestration/render.ts";
103
103
  import type { AgentResult, FailureKind } from "./orchestration/types.ts";
104
104
  import { type ModelHandle, PersonaController, type PersonaHost } from "./persona/controller.ts";
105
- import { resolveStrategyName, runPersonaStrategy } from "./persona/orchestrate.ts";
105
+ import { resolveStrategyName, resolveOrchestrationMembers, runPersonaStrategy } from "./persona/orchestrate.ts";
106
+ import {
107
+ collectModelParticipants,
108
+ pendingParticipants,
109
+ type ModelParticipant,
110
+ } from "./persona/model-participants.ts";
106
111
  import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
107
112
  import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
108
113
  import { readLastPersona, writeLastPersona } from "./persona/state.ts";
@@ -119,7 +124,7 @@ import { shortModel } from "./tools/delegate.ts";
119
124
  import { formatInbox } from "./tools/intercom.ts";
120
125
  import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
121
126
  import { AgentOverlay } from "./ui/agent-overlay.ts";
122
- import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
127
+ import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
123
128
  import { LiveClock } from "./ui/live-clock.ts";
124
129
  import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
125
130
  import { compactInlineText, compactVisibleText, sanitizeTerminalText } from "./ui/presentation.ts";
@@ -188,9 +193,14 @@ export function announceAsyncRunSettlement(
188
193
  enqueue: (run: AsyncRun) => void,
189
194
  ): void {
190
195
  const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
191
- const agent = compactInlineText(run.agent, { maxChars: 80 }) || "agent";
192
- if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
193
- else if (run.status === "failed") notify?.(`async run ${id} (${agent}) failed: ${compactInlineText(run.error ?? "(no detail)", { maxChars: 240 })}`, "error");
196
+ const agent = compactInlineText(run.label ?? run.agent, { maxChars: 80 }) || compactInlineText(run.agent, { maxChars: 80 }) || "agent";
197
+ try {
198
+ if (run.status === "done") notify?.(`async run ${id} (${agent}) completed`, "info");
199
+ else if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
200
+ else if (run.status === "failed") notify?.(`async run ${id} (${agent}) failed: ${compactInlineText(run.error ?? "(no detail)", { maxChars: 240 })}`, "error");
201
+ } catch {
202
+ // Cosmetic notification only: a broken UI must never swallow the terminal report.
203
+ }
194
204
  enqueue(run);
195
205
  }
196
206
 
@@ -636,6 +646,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
636
646
 
637
647
  // node id → steer that one agent (in-process engine only): inject a live user message.
638
648
  const steerRegistry = new Map<string, SteerFn>();
649
+ function waitingTreeIds(nodes: readonly AgentNode[] = agentTree.snapshot()): Set<string> {
650
+ return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
651
+ }
652
+ function waitingAsyncRunIds(): Set<string> {
653
+ return new Set(tracker.running().filter((run) => steerRegistry.get(`async:${run.id}`)?.isWaitingForSupervisor?.() === true).map((run) => run.id));
654
+ }
639
655
  const clearSteers = (prefix: string): void => {
640
656
  for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
641
657
  };
@@ -670,7 +686,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
670
686
  function renderAgentWidget(nodes: readonly AgentNode[] = agentTree.snapshot()): void {
671
687
  if (!lastCtx) return;
672
688
  const empty = nodes.length === 0;
673
- const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS });
689
+ const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
674
690
  const status = empty ? undefined : String(inFlightAgentCount(nodes));
675
691
  try {
676
692
  if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
@@ -783,6 +799,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
783
799
  canSteer: (id) => steerRegistry.has(id),
784
800
  canStop: (id) => stopRegistry.has(id),
785
801
  stallMs: STALL_FLAG_MS,
802
+ isWaitingForSupervisor: (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true,
786
803
  }),
787
804
  // Near-fullscreen: watching sub-agents work is a reading surface, not a popup.
788
805
  { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%" } },
@@ -813,7 +830,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
813
830
  return readPersonaConfigs(configFile);
814
831
  };
815
832
  let personaConfigs: PersonaConfigStore = {};
816
- const modelsPrompted = new Set<string>(); // ask-once-per-session guard
833
+ // A participant is asked for a model at most ONCE per session — dismissal included, so a
834
+ // closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
835
+ // roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
836
+ // it appears, while everyone already known stays silent.
837
+ const promptedParticipants = new Set<string>();
838
+ /** The subset of this run's participants already asked this session, keyed for the pure
839
+ * `pendingParticipants` gate (which speaks bare assignment keys). */
840
+ const promptedFor = (persona: string, participants: ModelParticipant[]): Set<string> => {
841
+ const asked = new Set<string>();
842
+ for (const p of participants) {
843
+ if (promptedParticipants.has(`${persona}\0${p.key}`)) asked.add(p.key);
844
+ }
845
+ return asked;
846
+ };
817
847
 
818
848
  // Personas/agents load ONLY from the user dir (populated by `/persona seed|restore`) and the
819
849
  // project `.pi/agents`. The bundled defaults are a *seed source*, NOT a live discovery layer,
@@ -1172,7 +1202,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1172
1202
  // the full status view stays on demand via `/peek`.
1173
1203
  const now = Date.now();
1174
1204
  const fast = config.peekEveryMs > 0;
1175
- const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS) : [];
1205
+ const waitingRuns = waitingAsyncRunIds();
1206
+ const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
1176
1207
  // Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
1177
1208
  // notifier and left for the `intercom inbox` tool — so peek never double-shows them.
1178
1209
  const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
@@ -1180,7 +1211,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1180
1211
  if (stuck.length === 0 && unread.length === 0 && !dueCheckIn) return; // healthy + quiet ⇒ stay silent
1181
1212
  const parts: string[] = [];
1182
1213
  if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
1183
- else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS }));
1214
+ else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }));
1184
1215
  if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1185
1216
  // Reset the check-in cadence only on a PROGRESS surfacing (a stall alert or a check-in), NOT on a
1186
1217
  // message-only wake — else a chatty child would postpone the routine off-track glance forever.
@@ -1283,27 +1314,54 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1283
1314
  };
1284
1315
  };
1285
1316
 
1286
- async function ensurePersonaModels(ctx: ExtensionContext, roster: RosterMember[]): Promise<void> {
1317
+ /**
1318
+ * Every participant a run really spawns — roster members (inline ones included) plus the
1319
+ * auxiliary actors its strategy declares — with the model each one resolves to.
1320
+ */
1321
+ function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
1322
+ const persona = controller.activePersona?.name;
1323
+ return collectModelParticipants({
1324
+ members: opts.members,
1325
+ strategy: opts.strategy,
1326
+ ...(opts.params ? { params: opts.params } : {}),
1327
+ ...(persona ? { assigned: personaModels(personaConfigs, persona) } : {}),
1328
+ agentModel: (agent) => agents.find((a) => a.name === agent)?.model,
1329
+ ...(ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {}),
1330
+ });
1331
+ }
1332
+
1333
+ /**
1334
+ * Offer a model for each participant that nobody has chosen one for yet. A participant is
1335
+ * skipped when a model is already assigned for it — an explicit inline member model, a saved
1336
+ * per-persona assignment (role key or agent key), or the agent's own frontmatter model. A saved
1337
+ * ref that is not currently authenticated is NOT re-asked either: that is a runtime recovery
1338
+ * question (the SDK reroutes such a leg to the main model), not a reason to nag every session.
1339
+ *
1340
+ * Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
1341
+ * authenticated model to choose between.
1342
+ */
1343
+ async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
1287
1344
  const persona = controller.activePersona?.name;
1288
1345
  if (!persona || !ctx.hasUI) return;
1289
- // A member that carries its own inline model needs no picked one; dedupe by agent name.
1290
- const pickable = [...new Set(roster.map((m) => rosterSpec(m)).filter((s) => !s.model).map((s) => s.agent))];
1291
- if (pickable.length < 2) return; // an ensemble of one distinct core can't be diversified
1292
- if (modelsPrompted.has(persona)) return;
1293
- const configured = personaConfigs[persona]?.models ?? {};
1294
- const missing = pickable.filter((a) => !configured[a]);
1346
+ const missing = pendingParticipants(participants, { prompted: promptedFor(persona, participants) });
1295
1347
  if (missing.length === 0) return;
1296
1348
  const available = configuredModels(ctx);
1297
1349
  if (available.length < 2) return; // can't diversify with a single configured model
1298
- modelsPrompted.add(persona);
1299
1350
  const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
1300
1351
  const chosen: Record<string, string> = {};
1301
1352
  try {
1302
- for (const agent of missing) {
1353
+ for (const p of missing) {
1354
+ // Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
1355
+ // for this session, exactly like an answer is not asked twice.
1356
+ promptedParticipants.add(`${persona}\0${p.key}`);
1303
1357
  // Name the core's verticalization in the picker too: choosing a model for "the
1304
1358
  // Conservatore" is a different judgement than choosing one for a bare "balthasar".
1305
- const purpose = corePurpose(agent);
1306
- const title = `Model for "${agent}"${purpose ? ` (${purpose})` : ""} · ${persona}`;
1359
+ // An auxiliary actor names the param that declared it ("arbiter judge").
1360
+ const purpose = corePurpose(p.agent);
1361
+ const role = p.param ? ` · ${p.param}` : "";
1362
+ // A participant label carries a role prompt (free prose from a team file or a
1363
+ // caller) — sanitised here so a title can never repaint the TUI or forge a row.
1364
+ const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}`, { maxChars: 120 });
1307
1365
  // In the TUI: a searchable picker (type to filter) whose viewport follows the
1308
1366
  // selection — the built-in select can't scroll a hundreds-long provider list
1309
1367
  // usefully. Outside the TUI (RPC), fall back to the built-in select.
@@ -1315,7 +1373,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1315
1373
  { overlay: true },
1316
1374
  )
1317
1375
  : await ctx.ui.select(title, options);
1318
- if (pick) chosen[agent] = pick;
1376
+ if (pick) chosen[p.key] = pick;
1319
1377
  }
1320
1378
  } catch {
1321
1379
  /* dismissed / no UI → fall back to the default model */
@@ -1342,24 +1400,40 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1342
1400
  return agents.find((a) => a.name === agent)?.purpose;
1343
1401
  }
1344
1402
 
1345
- /** The session's own model, for a strategy's last-resort recovery of a member whose model broke
1346
- * with no healthy peer to borrow from (`orchestration/model-retry.ts`). */
1347
- function sessionModelDep(ctx: ExtensionContext): { sessionModel?: string } {
1348
- return ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {};
1403
+ /** The session's own model, for a strategy's main-only recovery of a member whose model broke
1404
+ * (`orchestration/model-retry.ts`). The GETTER is the live
1405
+ * one: a leg can fail minutes after the run started, and the main model may have changed since
1406
+ * the snapshot was taken — the legacy string stays alongside it for callers that want the
1407
+ * value the run started with. */
1408
+ function sessionModelDep(ctx: ExtensionContext): { sessionModel?: string; getSessionModel?: () => string | undefined } {
1409
+ const ref = (c?: ExtensionContext): string | undefined => {
1410
+ const model = (c ?? lastCtx)?.model;
1411
+ return model ? `${model.provider}/${model.id}` : undefined;
1412
+ };
1413
+ const snapshot = ref(ctx);
1414
+ return {
1415
+ ...(snapshot ? { sessionModel: snapshot } : {}),
1416
+ getSessionModel: () => ref(ctx) ?? ref(),
1417
+ };
1349
1418
  }
1350
1419
 
1351
- // Each core's model beside its name: per-persona assignment → agent default → session.
1352
- function coreModel(ctx: ExtensionContext, agent: string): string | undefined {
1420
+ // Each core's model beside its name, in the ONE precedence the picker saved under: the
1421
+ // participant's own assignment key (a role member's `agent#hash`, else the bare agent key) →
1422
+ // the agent's declared default → the session model. An inline model is already the participant's
1423
+ // explicit engine pin and wins before any saved assignment.
1424
+ function coreModel(ctx: ExtensionContext, agent: string, assignmentKey: string = agent, participant?: ModelParticipant | null): string | undefined {
1425
+ if (participant === null) return undefined; // a short-hint collision cannot identify one role safely
1426
+ if (participant?.source === "inline" && participant.model) return participant.model;
1353
1427
  const persona = controller.activePersona?.name;
1354
1428
  const configured = persona ? personaModels(personaConfigs, persona) : {};
1355
- return configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
1429
+ return configured[assignmentKey] ?? configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
1356
1430
  }
1357
- function coreTelemetryMeta(ctx: ExtensionContext, agent: string): { agent: string; model?: string } {
1358
- const model = coreModel(ctx, agent);
1431
+ function coreTelemetryMeta(ctx: ExtensionContext, agent: string, assignmentKey?: string, participant?: ModelParticipant | null): { agent: string; model?: string } {
1432
+ const model = coreModel(ctx, agent, assignmentKey, participant);
1359
1433
  return { agent, ...(model ? { model } : {}) };
1360
1434
  }
1361
- function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent): string {
1362
- const short = shortModel(coreModel(ctx, agent));
1435
+ function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent, assignmentKey?: string, participant?: ModelParticipant | null): string {
1436
+ const short = shortModel(coreModel(ctx, agent, assignmentKey, participant));
1363
1437
  // `key` is the disambiguated node id (`agent` for a solo member, `agent · HINT` for a
1364
1438
  // roster-role one) — display that + what the core is FOR + the model, so a watcher reads the
1365
1439
  // council as roles rather than names, and three `reviewer` lenses still read distinctly.
@@ -1367,13 +1441,69 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1367
1441
  // purpose would repeat it — but a plain repeat (`melchior#2`) is the same core twice and
1368
1442
  // still wants its lens, so key off the role separator, not off `key !== agent`.
1369
1443
  const purpose = key.includes(" · ") ? undefined : corePurpose(agent);
1370
- return [key, purpose, short].filter(Boolean).join(" · ");
1444
+ // `key` embeds the member's role HINT (derived from its role prompt), and `short` embeds a
1445
+ // model ref — both are dynamic, so the whole row is collapsed to one escape-free line.
1446
+ return compactInlineText([key, purpose, short].filter(Boolean).join(" · "), { maxChars: 120 });
1447
+ }
1448
+
1449
+ /** The assignment key for a tree node key when no participant was seeded under it: strip the lens
1450
+ * hint and the occurrence suffix, leaving the bare agent — the legacy key shape, which always
1451
+ * resolves (a role-less agent is exactly how those assignments were saved). */
1452
+ function bareAssignmentKey(nodeKey: string): string {
1453
+ return (nodeKey.split(" · ")[0] ?? nodeKey).replace(/#\d+$/, "");
1454
+ }
1455
+
1456
+ /** Map every picker participant to the SDK's display base. A short role hint is not unique when
1457
+ * different role prompts share it, so ambiguous nodes omit the model rather than borrowing
1458
+ * another lens' assignment. Repeated SDK keys (`#2`, …) resolve through their base and share the
1459
+ * same participant. */
1460
+ function participantByNode(participants: ModelParticipant[]): Map<string, ModelParticipant | null> {
1461
+ const groups = new Map<string, Map<string, ModelParticipant>>();
1462
+ for (const participant of participants) {
1463
+ const base = participant.role ? `${participant.agent} · ${roleHint(participant.role)}` : participant.agent;
1464
+ const group = groups.get(base) ?? new Map<string, ModelParticipant>();
1465
+ group.set(participant.key, participant);
1466
+ groups.set(base, group);
1467
+ }
1468
+ const out = new Map<string, ModelParticipant | null>();
1469
+ for (const [base, group] of groups) {
1470
+ if (group.size !== 1) {
1471
+ out.set(base, null);
1472
+ continue;
1473
+ }
1474
+ const participant = group.values().next().value as ModelParticipant | undefined;
1475
+ if (participant) out.set(base, participant);
1476
+ }
1477
+ return out;
1478
+ }
1479
+
1480
+ function participantForNode(nodeParticipants: ReadonlyMap<string, ModelParticipant | null>, nodeKey: string): ModelParticipant | null | undefined {
1481
+ const exact = nodeParticipants.get(nodeKey);
1482
+ if (exact !== undefined) return exact;
1483
+ const base = nodeKey.replace(/#\d+$/, "");
1484
+ return base === nodeKey ? undefined : nodeParticipants.get(base);
1485
+ }
1486
+
1487
+ /** Surface the SDK's main-model recovery in the UI: a broken member is re-run on the session's own
1488
+ * model rather than dropped from the council, and the operator deserves to see that switch.
1489
+ * Strictly cosmetic — a toast that throws must never corrupt a run's result. */
1490
+ function modelFallbackDep(ctx: ExtensionContext): { onModelFallback?: (info: { agent: string; from?: string; to: string; key: string }) => void } {
1491
+ return {
1492
+ onModelFallback: (info) => {
1493
+ if (!ctx.hasUI) return;
1494
+ try {
1495
+ ctx.ui.notify(compactInlineText(`${info.agent}: ${info.from ?? "its model"} failed, re-running on ${info.to}`, { maxChars: 120 }), "warning");
1496
+ } catch {
1497
+ /* cosmetic */
1498
+ }
1499
+ },
1500
+ };
1371
1501
  }
1372
1502
 
1373
1503
  // The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
1374
1504
  // `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
1375
1505
  // cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
1376
- function strategyTreeDeps(ctx: ExtensionContext, rootId: string) {
1506
+ function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map()) {
1377
1507
  return {
1378
1508
  onAgentStart: (agent: string, abort: () => void, key?: string) => {
1379
1509
  stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
@@ -1390,7 +1520,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1390
1520
  // time, not run time, so a queued leg would otherwise show elapsed time it never
1391
1521
  // spent running and could raise a false stall badge once it goes live.
1392
1522
  const now = Date.now();
1393
- agentTree.add({ id, label: coreLabel(ctx, agent, nodeKey), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, agent), detail: "", startedAt: now, lastAdvanceAt: now });
1523
+ const participant = participantForNode(nodeParticipants, nodeKey);
1524
+ const assignmentKey = participant?.key ?? bareAssignmentKey(nodeKey);
1525
+ agentTree.add({ id, label: coreLabel(ctx, agent, nodeKey, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, agent, assignmentKey, participant), detail: "", startedAt: now, lastAdvanceAt: now });
1394
1526
  return;
1395
1527
  }
1396
1528
  stopRegistry.delete(id);
@@ -1406,7 +1538,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1406
1538
  onAgentProgress: (agent: string, p: AgentProgress, key?: string) => {
1407
1539
  const id = `${rootId}/${key ?? agent}`;
1408
1540
  if (p.toolEvent) publishAgentTool(id, p.toolEvent);
1409
- agentTree.update(id, progressPatch(p, Date.now()));
1541
+ const now = Date.now();
1542
+ agentTree.update(id, progressPatch(p, now));
1543
+ // Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
1544
+ agentTree.update(rootId, { lastAdvanceAt: now });
1410
1545
  },
1411
1546
  };
1412
1547
  }
@@ -1426,8 +1561,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1426
1561
  signal?: AbortSignal,
1427
1562
  ) {
1428
1563
  const label = resolveStrategyName(orch) ?? "strategy";
1429
- const roster = orch.roster ? (teams[orch.roster] ?? []) : [];
1430
- await ensurePersonaModels(ctx, roster);
1564
+ const roster = resolveOrchestrationMembers(orch, teams);
1565
+ const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
1566
+ await ensurePersonaModels(ctx, participants);
1567
+ const participantsByNode = participantByNode(participants);
1431
1568
  const rootId = nextRootId(`${idPrefix}:${label}`);
1432
1569
  agentTree.add({ id: rootId, label, status: "running", kind: "council" });
1433
1570
  // Seed the whole roster at once (cores show by name immediately); "queued" until the
@@ -1436,13 +1573,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1436
1573
  roster.forEach((m, i) => {
1437
1574
  const a = rosterSpec(m).agent;
1438
1575
  const key = seedKeys[i] ?? a;
1439
- agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a), detail: "queued" });
1576
+ const participant = participantForNode(participantsByNode, key);
1577
+ const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1578
+ 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" });
1440
1579
  });
1441
1580
  try {
1442
1581
  // The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
1443
1582
  // cooperatively between rounds, so an aborted run stops convening instead of running every
1444
1583
  // remaining round against an already-cancelled engine (docs/STRATEGIES.md).
1445
- const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), ...(signal ? { signal } : {}), ...strategyTreeDeps(ctx, rootId), ...strategySpawnGate() });
1584
+ 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) });
1446
1585
  agentTree.update(rootId, { status: signal?.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
1447
1586
  return result ?? undefined;
1448
1587
  } catch (error) {
@@ -1500,8 +1639,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1500
1639
  const journalPath = join(journalDir, journalFileName(spec)); // encodes the name — any name is storable
1501
1640
  const resume = readJournal(journalPath, hash);
1502
1641
 
1503
- const rosterMembers = spec.phases.flatMap((p) => (p.roster ? (teams[p.roster] ?? []) : []));
1504
- await ensurePersonaModels(ctx, rosterMembers);
1642
+ // Preflight: every participant the flow's phases will spawn — roster members AND the
1643
+ // auxiliary actors each phase's strategy declares (an arbiter, a synthesiser) — resolved
1644
+ // once, up front, so the whole run's models are settled before the first leg starts.
1645
+ // An unknown team resolves to nothing here; `verifyFlowRefs` reports it properly below.
1646
+ const phasePlans = new Map(
1647
+ spec.phases.map((phase) => {
1648
+ let members: RosterMember[] = [];
1649
+ try {
1650
+ members = resolveOrchestrationMembers(phase.roster ? { roster: phase.roster } : {}, teams);
1651
+ } catch {
1652
+ members = [];
1653
+ }
1654
+ const participants = runParticipants(ctx, { strategy: phase.strategy, ...(phase.params ? { params: phase.params } : {}), members });
1655
+ return [phase.id, { members, participants }] as const;
1656
+ }),
1657
+ );
1658
+ await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []));
1505
1659
 
1506
1660
  const flowRoot = nextRootId(`flow:${spec.name}`);
1507
1661
  agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
@@ -1546,23 +1700,28 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1546
1700
  },
1547
1701
  runPhase: async ({ phase, task }) => {
1548
1702
  const pid = `${flowRoot}/${phase.id}`;
1549
- const roster = phase.roster ? (teams[phase.roster] ?? []) : [];
1703
+ const plan = phasePlans.get(phase.id) ?? { members: [], participants: [] };
1704
+ const roster = plan.members;
1550
1705
  const seedKeys = rosterNodeKeys(roster);
1706
+ const participantsByNode = participantByNode(plan.participants);
1551
1707
  roster.forEach((m, i) => {
1552
1708
  const a = rosterSpec(m).agent;
1553
1709
  const key = seedKeys[i] ?? a;
1554
- agentTree.add({ id: `${pid}/${key}`, label: coreLabel(ctx, a, key), parentId: pid, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a), detail: "queued" });
1710
+ const participant = participantForNode(participantsByNode, key);
1711
+ const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1712
+ agentTree.add({ id: `${pid}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: pid, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
1555
1713
  });
1556
1714
  const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
1557
1715
  if (phase.roster) orch.roster = phase.roster;
1558
1716
  const r = await runPersonaStrategy(orch, task, {
1559
- engine: buildEngine(signal),
1717
+ engine: buildEngine(signal, undefined, { providerFallback: false }),
1560
1718
  teams,
1561
1719
  limits: RUN_LIMITS,
1562
1720
  ...sessionModelDep(ctx),
1563
1721
  ...(signal ? { signal } : {}), // cooperative per-round abort inside the phase's strategy, not just the engine
1564
- ...strategyTreeDeps(ctx, pid),
1722
+ ...strategyTreeDeps(ctx, pid, participantsByNode),
1565
1723
  ...strategySpawnGate(),
1724
+ ...modelFallbackDep(ctx),
1566
1725
  });
1567
1726
  return r ?? { agent: phase.id, output: `unknown strategy: ${phase.strategy}`, usage: emptyUsage(), ok: false, error: "unknown strategy" };
1568
1727
  },
@@ -1874,6 +2033,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1874
2033
  bus,
1875
2034
  SUPERVISOR,
1876
2035
  STALL_FLAG_MS,
2036
+ waitingRunIds: waitingAsyncRunIds,
1877
2037
  missingRunMessage,
1878
2038
  stopAgent,
1879
2039
  steerAgent,
@@ -2109,9 +2269,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2109
2269
  const id = args.trim();
2110
2270
  if (id) {
2111
2271
  const run = tracker.peek(id);
2112
- ctx.ui.notify(run ? buildPeekDigest([run], { now: Date.now(), stallMs: STALL_FLAG_MS }) : `peek: no run "${id}"`, run ? "info" : "warning");
2272
+ ctx.ui.notify(run ? buildPeekDigest([run], { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }) : `peek: no run "${id}"`, run ? "info" : "warning");
2113
2273
  } else {
2114
- ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS }), "info");
2274
+ ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }), "info");
2115
2275
  }
2116
2276
  },
2117
2277
  });