@aeondave/pi-persona 1.15.2 → 1.16.1

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 +32 -6
  2. package/CHANGELOG.md +97 -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 +14 -0
  11. package/package.json +18 -11
  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 +233 -57
  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
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
 
@@ -525,6 +535,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
525
535
  // left hanging when Pi resolves a call as "immediate" and never reaches a result.
526
536
  type PendingIntercomTelemetry = { kind: string; to: string; size: number; replyTo?: string };
527
537
  const telemetryIntercomPending = new Map<string, PendingIntercomTelemetry>();
538
+ // node id → steer that one agent (in-process engine only): inject a live user message.
539
+ const steerRegistry = new Map<string, SteerFn>();
528
540
  function telemetryKind(node: AgentNode): TelemetryAgentKind {
529
541
  if (node.kind) return node.kind;
530
542
  if (node.id.startsWith("flow:")) return "flow";
@@ -537,9 +549,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
537
549
  // "queued" is a marker WE set (the seeded roster / a launched async run), so it is a real
538
550
  // state. Nothing ever sets `detail` to a waiting state — it is a usage string, an error, or
539
551
  // `toolActivity(name, args)`, which splices the tool's own argument text — so a substring
540
- // test for "waiting" could only ever fire on a leg that is running normally.
552
+ // test for "waiting" could only ever fire on a leg that is running normally. A live in-process
553
+ // steer handle has the authoritative bus predicate for a supervisor ask.
541
554
  if (isQueuedMarker(node.detail)) return "queued";
542
- return "running";
555
+ return steerRegistry.get(node.id)?.isWaitingForSupervisor?.() === true ? "waiting" : "running";
543
556
  }
544
557
  function telemetryAgent(node: AgentNode): TelemetryAgentInput {
545
558
  return {
@@ -575,6 +588,22 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
575
588
  // unfiltered publish appends one byte-identical duplicate per chunk: a 5k-chunk leg writes
576
589
  // ~1.5MB of them and evicts genuine history from the producer's file budget.
577
590
  const lastAgentProjection = new Map<string, string>();
591
+ function publishAgentProjection(node: AgentNode): void {
592
+ const producer = telemetry;
593
+ if (!producer) return;
594
+ const agent = telemetryAgent(node);
595
+ const projection = JSON.stringify(agent);
596
+ if (lastAgentProjection.get(node.id) === projection) return;
597
+ lastAgentProjection.set(node.id, projection);
598
+ producer.publishAgentUpdated(node.id, agent);
599
+ }
600
+ function refreshSteeredAgentTelemetry(): void {
601
+ if (!telemetry || steerRegistry.size === 0) return;
602
+ const steeredIds = new Set(steerRegistry.keys());
603
+ for (const node of agentTree.snapshot()) {
604
+ if (steeredIds.has(node.id)) publishAgentProjection(node);
605
+ }
606
+ }
578
607
  function publishAgentTreeChange(change: AgentTreeChange): void {
579
608
  const producer = telemetry;
580
609
  if (!producer) return;
@@ -584,10 +613,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
584
613
  return;
585
614
  }
586
615
  if (change.type === "updated") {
587
- const projection = JSON.stringify(telemetryAgent(change.node));
588
- if (lastAgentProjection.get(change.node.id) === projection) return;
589
- lastAgentProjection.set(change.node.id, projection);
590
- producer.publishAgentUpdated(change.node.id, telemetryAgent(change.node));
616
+ publishAgentProjection(change.node);
591
617
  return;
592
618
  }
593
619
  if (change.type === "removed") {
@@ -634,8 +660,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
634
660
  return true;
635
661
  }
636
662
 
637
- // node id → steer that one agent (in-process engine only): inject a live user message.
638
- const steerRegistry = new Map<string, SteerFn>();
663
+ function waitingTreeIds(nodes: readonly AgentNode[] = agentTree.snapshot()): Set<string> {
664
+ return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
665
+ }
666
+ function waitingAsyncRunIds(): Set<string> {
667
+ return new Set(tracker.running().filter((run) => steerRegistry.get(`async:${run.id}`)?.isWaitingForSupervisor?.() === true).map((run) => run.id));
668
+ }
639
669
  const clearSteers = (prefix: string): void => {
640
670
  for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
641
671
  };
@@ -670,7 +700,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
670
700
  function renderAgentWidget(nodes: readonly AgentNode[] = agentTree.snapshot()): void {
671
701
  if (!lastCtx) return;
672
702
  const empty = nodes.length === 0;
673
- const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS });
703
+ const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
674
704
  const status = empty ? undefined : String(inFlightAgentCount(nodes));
675
705
  try {
676
706
  if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
@@ -783,6 +813,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
783
813
  canSteer: (id) => steerRegistry.has(id),
784
814
  canStop: (id) => stopRegistry.has(id),
785
815
  stallMs: STALL_FLAG_MS,
816
+ isWaitingForSupervisor: (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true,
786
817
  }),
787
818
  // Near-fullscreen: watching sub-agents work is a reading surface, not a popup.
788
819
  { overlay: true, overlayOptions: { width: "90%", maxHeight: "90%" } },
@@ -813,7 +844,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
813
844
  return readPersonaConfigs(configFile);
814
845
  };
815
846
  let personaConfigs: PersonaConfigStore = {};
816
- const modelsPrompted = new Set<string>(); // ask-once-per-session guard
847
+ // A participant is asked for a model at most ONCE per session — dismissal included, so a
848
+ // closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
849
+ // roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
850
+ // it appears, while everyone already known stays silent.
851
+ const promptedParticipants = new Set<string>();
852
+ /** The subset of this run's participants already asked this session, keyed for the pure
853
+ * `pendingParticipants` gate (which speaks bare assignment keys). */
854
+ const promptedFor = (persona: string, participants: ModelParticipant[]): Set<string> => {
855
+ const asked = new Set<string>();
856
+ for (const p of participants) {
857
+ if (promptedParticipants.has(`${persona}\0${p.key}`)) asked.add(p.key);
858
+ }
859
+ return asked;
860
+ };
817
861
 
818
862
  // Personas/agents load ONLY from the user dir (populated by `/persona seed|restore`) and the
819
863
  // project `.pi/agents`. The bundled defaults are a *seed source*, NOT a live discovery layer,
@@ -1033,6 +1077,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1033
1077
  bus.onAskSettled(({ id }) => {
1034
1078
  intercomNotifier.discard((ask) => ask.askId === id);
1035
1079
  telemetryAskSenders.delete(id);
1080
+ if (!disposed) refreshSteeredAgentTelemetry();
1036
1081
  });
1037
1082
  // Supervisor-armable alarms: when a timer expires it WAKES the session by routing the fire
1038
1083
  // through the same idle-delivery path (an idle delivery starts a fresh turn, so the supervisor
@@ -1114,6 +1159,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1114
1159
  expectsReply: env.expectsReply,
1115
1160
  size: Buffer.byteLength(env.text, "utf8"),
1116
1161
  });
1162
+ if (!disposed && env.expectsReply) refreshSteeredAgentTelemetry();
1117
1163
  if (disposed || env.to !== SUPERVISOR || !env.expectsReply) return;
1118
1164
  // Only an ask can be replied to, so only an ask needs its sender remembered. Oldest first
1119
1165
  // out: a long session must not grow this without bound.
@@ -1172,7 +1218,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1172
1218
  // the full status view stays on demand via `/peek`.
1173
1219
  const now = Date.now();
1174
1220
  const fast = config.peekEveryMs > 0;
1175
- const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS) : [];
1221
+ const waitingRuns = waitingAsyncRunIds();
1222
+ const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
1176
1223
  // Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
1177
1224
  // notifier and left for the `intercom inbox` tool — so peek never double-shows them.
1178
1225
  const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
@@ -1180,7 +1227,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1180
1227
  if (stuck.length === 0 && unread.length === 0 && !dueCheckIn) return; // healthy + quiet ⇒ stay silent
1181
1228
  const parts: string[] = [];
1182
1229
  if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
1183
- else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS }));
1230
+ else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }));
1184
1231
  if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1185
1232
  // Reset the check-in cadence only on a PROGRESS surfacing (a stall alert or a check-in), NOT on a
1186
1233
  // message-only wake — else a chatty child would postpone the routine off-track glance forever.
@@ -1283,27 +1330,54 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1283
1330
  };
1284
1331
  };
1285
1332
 
1286
- async function ensurePersonaModels(ctx: ExtensionContext, roster: RosterMember[]): Promise<void> {
1333
+ /**
1334
+ * Every participant a run really spawns — roster members (inline ones included) plus the
1335
+ * auxiliary actors its strategy declares — with the model each one resolves to.
1336
+ */
1337
+ function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
1338
+ const persona = controller.activePersona?.name;
1339
+ return collectModelParticipants({
1340
+ members: opts.members,
1341
+ strategy: opts.strategy,
1342
+ ...(opts.params ? { params: opts.params } : {}),
1343
+ ...(persona ? { assigned: personaModels(personaConfigs, persona) } : {}),
1344
+ agentModel: (agent) => agents.find((a) => a.name === agent)?.model,
1345
+ ...(ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {}),
1346
+ });
1347
+ }
1348
+
1349
+ /**
1350
+ * Offer a model for each participant that nobody has chosen one for yet. A participant is
1351
+ * skipped when a model is already assigned for it — an explicit inline member model, a saved
1352
+ * per-persona assignment (role key or agent key), or the agent's own frontmatter model. A saved
1353
+ * ref that is not currently authenticated is NOT re-asked either: that is a runtime recovery
1354
+ * question (the SDK reroutes such a leg to the main model), not a reason to nag every session.
1355
+ *
1356
+ * Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
1357
+ * authenticated model to choose between.
1358
+ */
1359
+ async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
1287
1360
  const persona = controller.activePersona?.name;
1288
1361
  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]);
1362
+ const missing = pendingParticipants(participants, { prompted: promptedFor(persona, participants) });
1295
1363
  if (missing.length === 0) return;
1296
1364
  const available = configuredModels(ctx);
1297
1365
  if (available.length < 2) return; // can't diversify with a single configured model
1298
- modelsPrompted.add(persona);
1299
1366
  const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
1300
1367
  const chosen: Record<string, string> = {};
1301
1368
  try {
1302
- for (const agent of missing) {
1369
+ for (const p of missing) {
1370
+ // Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
1371
+ // for this session, exactly like an answer is not asked twice.
1372
+ promptedParticipants.add(`${persona}\0${p.key}`);
1303
1373
  // Name the core's verticalization in the picker too: choosing a model for "the
1304
1374
  // 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}`;
1375
+ // An auxiliary actor names the param that declared it ("arbiter judge").
1376
+ const purpose = corePurpose(p.agent);
1377
+ const role = p.param ? ` · ${p.param}` : "";
1378
+ // A participant label carries a role prompt (free prose from a team file or a
1379
+ // caller) — sanitised here so a title can never repaint the TUI or forge a row.
1380
+ const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}`, { maxChars: 120 });
1307
1381
  // In the TUI: a searchable picker (type to filter) whose viewport follows the
1308
1382
  // selection — the built-in select can't scroll a hundreds-long provider list
1309
1383
  // usefully. Outside the TUI (RPC), fall back to the built-in select.
@@ -1315,7 +1389,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1315
1389
  { overlay: true },
1316
1390
  )
1317
1391
  : await ctx.ui.select(title, options);
1318
- if (pick) chosen[agent] = pick;
1392
+ if (pick) chosen[p.key] = pick;
1319
1393
  }
1320
1394
  } catch {
1321
1395
  /* dismissed / no UI → fall back to the default model */
@@ -1342,24 +1416,40 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1342
1416
  return agents.find((a) => a.name === agent)?.purpose;
1343
1417
  }
1344
1418
 
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}` } : {};
1419
+ /** The session's own model, for a strategy's main-only recovery of a member whose model broke
1420
+ * (`orchestration/model-retry.ts`). The GETTER is the live
1421
+ * one: a leg can fail minutes after the run started, and the main model may have changed since
1422
+ * the snapshot was taken — the legacy string stays alongside it for callers that want the
1423
+ * value the run started with. */
1424
+ function sessionModelDep(ctx: ExtensionContext): { sessionModel?: string; getSessionModel?: () => string | undefined } {
1425
+ const ref = (c?: ExtensionContext): string | undefined => {
1426
+ const model = (c ?? lastCtx)?.model;
1427
+ return model ? `${model.provider}/${model.id}` : undefined;
1428
+ };
1429
+ const snapshot = ref(ctx);
1430
+ return {
1431
+ ...(snapshot ? { sessionModel: snapshot } : {}),
1432
+ getSessionModel: () => ref(ctx) ?? ref(),
1433
+ };
1349
1434
  }
1350
1435
 
1351
- // Each core's model beside its name: per-persona assignment → agent default → session.
1352
- function coreModel(ctx: ExtensionContext, agent: string): string | undefined {
1436
+ // Each core's model beside its name, in the ONE precedence the picker saved under: the
1437
+ // participant's own assignment key (a role member's `agent#hash`, else the bare agent key) →
1438
+ // the agent's declared default → the session model. An inline model is already the participant's
1439
+ // explicit engine pin and wins before any saved assignment.
1440
+ function coreModel(ctx: ExtensionContext, agent: string, assignmentKey: string = agent, participant?: ModelParticipant | null): string | undefined {
1441
+ if (participant === null) return undefined; // a short-hint collision cannot identify one role safely
1442
+ if (participant?.source === "inline" && participant.model) return participant.model;
1353
1443
  const persona = controller.activePersona?.name;
1354
1444
  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);
1445
+ return configured[assignmentKey] ?? configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
1356
1446
  }
1357
- function coreTelemetryMeta(ctx: ExtensionContext, agent: string): { agent: string; model?: string } {
1358
- const model = coreModel(ctx, agent);
1447
+ function coreTelemetryMeta(ctx: ExtensionContext, agent: string, assignmentKey?: string, participant?: ModelParticipant | null): { agent: string; model?: string } {
1448
+ const model = coreModel(ctx, agent, assignmentKey, participant);
1359
1449
  return { agent, ...(model ? { model } : {}) };
1360
1450
  }
1361
- function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent): string {
1362
- const short = shortModel(coreModel(ctx, agent));
1451
+ function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent, assignmentKey?: string, participant?: ModelParticipant | null): string {
1452
+ const short = shortModel(coreModel(ctx, agent, assignmentKey, participant));
1363
1453
  // `key` is the disambiguated node id (`agent` for a solo member, `agent · HINT` for a
1364
1454
  // roster-role one) — display that + what the core is FOR + the model, so a watcher reads the
1365
1455
  // council as roles rather than names, and three `reviewer` lenses still read distinctly.
@@ -1367,13 +1457,69 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1367
1457
  // purpose would repeat it — but a plain repeat (`melchior#2`) is the same core twice and
1368
1458
  // still wants its lens, so key off the role separator, not off `key !== agent`.
1369
1459
  const purpose = key.includes(" · ") ? undefined : corePurpose(agent);
1370
- return [key, purpose, short].filter(Boolean).join(" · ");
1460
+ // `key` embeds the member's role HINT (derived from its role prompt), and `short` embeds a
1461
+ // model ref — both are dynamic, so the whole row is collapsed to one escape-free line.
1462
+ return compactInlineText([key, purpose, short].filter(Boolean).join(" · "), { maxChars: 120 });
1463
+ }
1464
+
1465
+ /** The assignment key for a tree node key when no participant was seeded under it: strip the lens
1466
+ * hint and the occurrence suffix, leaving the bare agent — the legacy key shape, which always
1467
+ * resolves (a role-less agent is exactly how those assignments were saved). */
1468
+ function bareAssignmentKey(nodeKey: string): string {
1469
+ return (nodeKey.split(" · ")[0] ?? nodeKey).replace(/#\d+$/, "");
1470
+ }
1471
+
1472
+ /** Map every picker participant to the SDK's display base. A short role hint is not unique when
1473
+ * different role prompts share it, so ambiguous nodes omit the model rather than borrowing
1474
+ * another lens' assignment. Repeated SDK keys (`#2`, …) resolve through their base and share the
1475
+ * same participant. */
1476
+ function participantByNode(participants: ModelParticipant[]): Map<string, ModelParticipant | null> {
1477
+ const groups = new Map<string, Map<string, ModelParticipant>>();
1478
+ for (const participant of participants) {
1479
+ const base = participant.role ? `${participant.agent} · ${roleHint(participant.role)}` : participant.agent;
1480
+ const group = groups.get(base) ?? new Map<string, ModelParticipant>();
1481
+ group.set(participant.key, participant);
1482
+ groups.set(base, group);
1483
+ }
1484
+ const out = new Map<string, ModelParticipant | null>();
1485
+ for (const [base, group] of groups) {
1486
+ if (group.size !== 1) {
1487
+ out.set(base, null);
1488
+ continue;
1489
+ }
1490
+ const participant = group.values().next().value as ModelParticipant | undefined;
1491
+ if (participant) out.set(base, participant);
1492
+ }
1493
+ return out;
1494
+ }
1495
+
1496
+ function participantForNode(nodeParticipants: ReadonlyMap<string, ModelParticipant | null>, nodeKey: string): ModelParticipant | null | undefined {
1497
+ const exact = nodeParticipants.get(nodeKey);
1498
+ if (exact !== undefined) return exact;
1499
+ const base = nodeKey.replace(/#\d+$/, "");
1500
+ return base === nodeKey ? undefined : nodeParticipants.get(base);
1501
+ }
1502
+
1503
+ /** Surface the SDK's main-model recovery in the UI: a broken member is re-run on the session's own
1504
+ * model rather than dropped from the council, and the operator deserves to see that switch.
1505
+ * Strictly cosmetic — a toast that throws must never corrupt a run's result. */
1506
+ function modelFallbackDep(ctx: ExtensionContext): { onModelFallback?: (info: { agent: string; from?: string; to: string; key: string }) => void } {
1507
+ return {
1508
+ onModelFallback: (info) => {
1509
+ if (!ctx.hasUI) return;
1510
+ try {
1511
+ ctx.ui.notify(compactInlineText(`${info.agent}: ${info.from ?? "its model"} failed, re-running on ${info.to}`, { maxChars: 120 }), "warning");
1512
+ } catch {
1513
+ /* cosmetic */
1514
+ }
1515
+ },
1516
+ };
1371
1517
  }
1372
1518
 
1373
1519
  // The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
1374
1520
  // `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
1375
1521
  // cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
1376
- function strategyTreeDeps(ctx: ExtensionContext, rootId: string) {
1522
+ function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map()) {
1377
1523
  return {
1378
1524
  onAgentStart: (agent: string, abort: () => void, key?: string) => {
1379
1525
  stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
@@ -1390,7 +1536,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1390
1536
  // time, not run time, so a queued leg would otherwise show elapsed time it never
1391
1537
  // spent running and could raise a false stall badge once it goes live.
1392
1538
  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 });
1539
+ const participant = participantForNode(nodeParticipants, nodeKey);
1540
+ const assignmentKey = participant?.key ?? bareAssignmentKey(nodeKey);
1541
+ 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
1542
  return;
1395
1543
  }
1396
1544
  stopRegistry.delete(id);
@@ -1406,7 +1554,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1406
1554
  onAgentProgress: (agent: string, p: AgentProgress, key?: string) => {
1407
1555
  const id = `${rootId}/${key ?? agent}`;
1408
1556
  if (p.toolEvent) publishAgentTool(id, p.toolEvent);
1409
- agentTree.update(id, progressPatch(p, Date.now()));
1557
+ const now = Date.now();
1558
+ agentTree.update(id, progressPatch(p, now));
1559
+ // Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
1560
+ agentTree.update(rootId, { lastAdvanceAt: now });
1410
1561
  },
1411
1562
  };
1412
1563
  }
@@ -1426,8 +1577,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1426
1577
  signal?: AbortSignal,
1427
1578
  ) {
1428
1579
  const label = resolveStrategyName(orch) ?? "strategy";
1429
- const roster = orch.roster ? (teams[orch.roster] ?? []) : [];
1430
- await ensurePersonaModels(ctx, roster);
1580
+ const roster = resolveOrchestrationMembers(orch, teams);
1581
+ const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
1582
+ await ensurePersonaModels(ctx, participants);
1583
+ const participantsByNode = participantByNode(participants);
1431
1584
  const rootId = nextRootId(`${idPrefix}:${label}`);
1432
1585
  agentTree.add({ id: rootId, label, status: "running", kind: "council" });
1433
1586
  // Seed the whole roster at once (cores show by name immediately); "queued" until the
@@ -1436,13 +1589,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1436
1589
  roster.forEach((m, i) => {
1437
1590
  const a = rosterSpec(m).agent;
1438
1591
  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" });
1592
+ const participant = participantForNode(participantsByNode, key);
1593
+ const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1594
+ 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
1595
  });
1441
1596
  try {
1442
1597
  // The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
1443
1598
  // cooperatively between rounds, so an aborted run stops convening instead of running every
1444
1599
  // 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() });
1600
+ 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
1601
  agentTree.update(rootId, { status: signal?.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
1447
1602
  return result ?? undefined;
1448
1603
  } catch (error) {
@@ -1500,8 +1655,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1500
1655
  const journalPath = join(journalDir, journalFileName(spec)); // encodes the name — any name is storable
1501
1656
  const resume = readJournal(journalPath, hash);
1502
1657
 
1503
- const rosterMembers = spec.phases.flatMap((p) => (p.roster ? (teams[p.roster] ?? []) : []));
1504
- await ensurePersonaModels(ctx, rosterMembers);
1658
+ // Preflight: every participant the flow's phases will spawn — roster members AND the
1659
+ // auxiliary actors each phase's strategy declares (an arbiter, a synthesiser) — resolved
1660
+ // once, up front, so the whole run's models are settled before the first leg starts.
1661
+ // An unknown team resolves to nothing here; `verifyFlowRefs` reports it properly below.
1662
+ const phasePlans = new Map(
1663
+ spec.phases.map((phase) => {
1664
+ let members: RosterMember[] = [];
1665
+ try {
1666
+ members = resolveOrchestrationMembers(phase.roster ? { roster: phase.roster } : {}, teams);
1667
+ } catch {
1668
+ members = [];
1669
+ }
1670
+ const participants = runParticipants(ctx, { strategy: phase.strategy, ...(phase.params ? { params: phase.params } : {}), members });
1671
+ return [phase.id, { members, participants }] as const;
1672
+ }),
1673
+ );
1674
+ await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []));
1505
1675
 
1506
1676
  const flowRoot = nextRootId(`flow:${spec.name}`);
1507
1677
  agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
@@ -1546,23 +1716,28 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1546
1716
  },
1547
1717
  runPhase: async ({ phase, task }) => {
1548
1718
  const pid = `${flowRoot}/${phase.id}`;
1549
- const roster = phase.roster ? (teams[phase.roster] ?? []) : [];
1719
+ const plan = phasePlans.get(phase.id) ?? { members: [], participants: [] };
1720
+ const roster = plan.members;
1550
1721
  const seedKeys = rosterNodeKeys(roster);
1722
+ const participantsByNode = participantByNode(plan.participants);
1551
1723
  roster.forEach((m, i) => {
1552
1724
  const a = rosterSpec(m).agent;
1553
1725
  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" });
1726
+ const participant = participantForNode(participantsByNode, key);
1727
+ const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1728
+ 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
1729
  });
1556
1730
  const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
1557
1731
  if (phase.roster) orch.roster = phase.roster;
1558
1732
  const r = await runPersonaStrategy(orch, task, {
1559
- engine: buildEngine(signal),
1733
+ engine: buildEngine(signal, undefined, { providerFallback: false }),
1560
1734
  teams,
1561
1735
  limits: RUN_LIMITS,
1562
1736
  ...sessionModelDep(ctx),
1563
1737
  ...(signal ? { signal } : {}), // cooperative per-round abort inside the phase's strategy, not just the engine
1564
- ...strategyTreeDeps(ctx, pid),
1738
+ ...strategyTreeDeps(ctx, pid, participantsByNode),
1565
1739
  ...strategySpawnGate(),
1740
+ ...modelFallbackDep(ctx),
1566
1741
  });
1567
1742
  return r ?? { agent: phase.id, output: `unknown strategy: ${phase.strategy}`, usage: emptyUsage(), ok: false, error: "unknown strategy" };
1568
1743
  },
@@ -1874,6 +2049,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1874
2049
  bus,
1875
2050
  SUPERVISOR,
1876
2051
  STALL_FLAG_MS,
2052
+ waitingRunIds: waitingAsyncRunIds,
1877
2053
  missingRunMessage,
1878
2054
  stopAgent,
1879
2055
  steerAgent,
@@ -2109,9 +2285,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2109
2285
  const id = args.trim();
2110
2286
  if (id) {
2111
2287
  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");
2288
+ ctx.ui.notify(run ? buildPeekDigest([run], { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }) : `peek: no run "${id}"`, run ? "info" : "warning");
2113
2289
  } else {
2114
- ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS }), "info");
2290
+ ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }), "info");
2115
2291
  }
2116
2292
  },
2117
2293
  });
@@ -1,26 +1,20 @@
1
1
  /**
2
- * Peer-model retry policy — when one council member fails for a MODEL reason, which model
3
- * should it be re-run on?
2
+ * Main-only model retry policy — when a leg fails for a MODEL reason, which model should it be
3
+ * re-run on? Exactly one: the session's own (main) model.
4
4
  *
5
- * This is the roster-level complement to `engine/fallback.ts`. That decorator reroutes the SAME
6
- * model id to a family-compatible provider (anthropic/x → bedrock/x) and knows nothing about the
7
- * other members; this decides to hand a broken core a DIFFERENT model that a sibling just proved
8
- * works in this very session. The two compose: the engine exhausts provider routes first, and only
9
- * a run that still comes back `provider`/`unknown-model` reaches here.
5
+ * That single candidate is a deliberate, user-authorised exception to the strict provider-pin rule
6
+ * (`engine/fallback.ts` never crosses a pinned provider, and a strategy never picks another
7
+ * provider's model): a previously chosen, persisted, or inline-pinned model can simply be
8
+ * UNREACHABLE — retired ref, dead auth, vanished route — and the model the user is running right
9
+ * now is the only one with live evidence behind it. Earlier this policy also preferred a healthy
10
+ * PEER's model; that is gone. Borrowing one silently re-shaped the panel (breaking a council's
11
+ * uncorrelated-error bias guard for `magi`/`judge`) and crossed somebody else's provider pin, so
12
+ * recovery is now main-only and disclosed. The SDK applies it; this module is the pure policy.
10
13
  *
11
- * The order of preference is evidence, strongest first:
12
- * 1. the session's own model, when a peer proved it — the user is running on it, so it is the
13
- * most likely to be authed, in budget, and available;
14
- * 2. otherwise the first healthy peer's model in roster order (deterministic, not iteration order);
15
- * 3. otherwise the session model unproven — the case where the failing core is the first or the
16
- * only one left, so no peer evidence exists at all.
17
- *
18
- * A council's whole value is UNCORRELATED errors from distinct reasoners, so this deliberately
19
- * never runs as a preference: it is what keeps a broken core from silently dropping out of the
20
- * vote, and callers are expected to surface it. Pure — no engine, no I/O, no clock.
14
+ * Pure — no engine, no I/O, no clock.
21
15
  */
22
16
 
23
- import type { AgentResult, FailureKind } from "./types.ts";
17
+ import type { AgentResult, FailureKind, ModelRecovery } from "./types.ts";
24
18
 
25
19
  /** The only failures a DIFFERENT model can fix. `provider` is the provider rejecting or breaking
26
20
  * (auth, outage, 5xx, model-not-supported); `unknown-model` is a ref that does not resolve at all.
@@ -31,55 +25,32 @@ export const RETRYABLE_MODEL_FAILURES = ["provider", "unknown-model"] as const s
31
25
 
32
26
  const retryable = new Set<string>(RETRYABLE_MODEL_FAILURES);
33
27
 
34
- export interface ModelRetry {
35
- /** Position in the round's results, so the caller can re-run exactly this member. */
36
- index: number;
37
- agent: string;
38
- /** The model that just failed, when the engine resolved one. */
39
- from?: string;
40
- /** The model to re-run on — never the one that just failed. */
41
- model: string;
42
- /** Which evidence chose it, for an honest breadcrumb in the log and the tree. */
43
- reason: "peer" | "session";
28
+ /** A planned single recovery. `reason` is always `"session"`: there is no other source. */
29
+ export interface ModelRetry extends ModelRecovery {
30
+ reason: "session";
44
31
  }
45
32
 
46
33
  export interface ModelRetryDeps {
47
- /** The model the user's own session runs on (`provider/id`), when known. */
34
+ /** The model's `provider/id` — the run's MAIN model. */
48
35
  sessionModel?: string;
36
+ /** The model this leg ASKED for (`spec.model`), which the engine may never have resolved. */
37
+ requested?: string;
49
38
  }
50
39
 
51
40
  /**
52
- * Plan at most one retry per failed member. Returns an empty list when nothing is worth
53
- * re-running — no model-caused failure, or no model available that differs from the one that
54
- * already failed.
41
+ * Plan at most one recovery for one failed leg, or `null` when nothing is worth re-running:
42
+ * the leg succeeded, the failure was not model-caused, no main model is known, or the main model is
43
+ * the one that just failed (resolved OR requested) — re-running there is a second bill for an
44
+ * identical outcome.
55
45
  */
56
- export function planModelRetries(results: readonly AgentResult[], deps: ModelRetryDeps): ModelRetry[] {
57
- const healthy = results.filter((r) => r.ok && r.modelUsed).map((r) => r.modelUsed as string);
58
- const session = deps.sessionModel?.trim() || undefined;
59
- // A peer's model is evidence; the session model is evidence too when a peer used it, and a
60
- // reasonable guess when nothing survived to prove anything.
61
- const candidates = session && healthy.includes(session)
62
- ? [session, ...healthy.filter((model) => model !== session)]
63
- : healthy.length > 0
64
- ? healthy
65
- : session
66
- ? [session]
67
- : [];
68
-
69
- const plan: ModelRetry[] = [];
70
- results.forEach((result, index) => {
71
- if (result.ok || !result.failureKind || !retryable.has(result.failureKind)) return;
72
- // Re-running on the model that just failed cannot produce a different outcome. Keep walking:
73
- // a later healthy peer may still provide a distinct model worth trying.
74
- const candidate = candidates.find((model) => model !== result.modelUsed);
75
- if (!candidate) return;
76
- plan.push({
77
- index,
78
- agent: result.agent,
79
- ...(result.modelUsed ? { from: result.modelUsed } : {}),
80
- model: candidate,
81
- reason: healthy.includes(candidate) ? "peer" : "session",
82
- });
83
- });
84
- return plan;
46
+ export function planModelRecovery(result: AgentResult, deps: ModelRetryDeps): ModelRetry | null {
47
+ if (result.ok || !result.failureKind || !retryable.has(result.failureKind)) return null;
48
+ const main = deps.sessionModel?.trim();
49
+ if (!main) return null;
50
+ if (main === result.modelUsed || main === deps.requested) return null;
51
+ return {
52
+ ...(result.modelUsed ? { from: result.modelUsed } : {}),
53
+ to: main,
54
+ reason: "session",
55
+ };
85
56
  }