@aeondave/pi-persona 1.17.4 → 1.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/extension.ts CHANGED
@@ -37,7 +37,8 @@ import {
37
37
  type SpineLegacySelection,
38
38
  } from "./core/seed.ts";
39
39
  import { type BriefFlow, buildDelegationBrief } from "./core/brief.ts";
40
- import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
40
+ import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
41
+ import { isAllowed } from "./core/permissions.ts";
41
42
  import { fenceUntrusted } from "./core/fence.ts";
42
43
  import { sanitizeDisplayLabel } from "./core/display-label.ts";
43
44
  import { DelegationNudge, orderByMention, PersistenceNudge, PollGuard, renderNudgeModels, renderNudgeRoster } from "./core/nudge.ts";
@@ -48,7 +49,11 @@ import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-co
48
49
  import { type EngineAdapterBroker } from "./engine/adapter.ts";
49
50
  import { SupervisorBroker } from "./extension/broker-host.ts";
50
51
  import { configuredModels, createBuildEngine, DEFAULT_ENGINE_FACTORIES, type EngineFactories } from "./extension/engine.ts";
51
- import { installHooks, type HookHost } from "./extension/hooks.ts";
52
+ import { installHooks, type HookHost } from "./extension/hooks.ts";
53
+ import { installActivityIngress, installCompanionPresence } from "./extension/companion.ts";
54
+ import { installMemoryOwner } from "./extension/memory-owner.ts";
55
+ import { installMindWorker } from "./extension/mind-worker.ts";
56
+ import { inheritedMemoryPersona, MEMORY_PERSONA_ENV } from "./core/companion.ts";
52
57
  import {
53
58
  canDeliverPersonaNotification,
54
59
  compactNameList,
@@ -110,14 +115,16 @@ import { clearJournal, journalFileName, journalWriter, readJournal } from "./orc
110
115
  import { runFlow } from "./orchestration/flow-run.ts";
111
116
  import { Semaphore } from "./orchestration/parallel.ts";
112
117
  import { flowDisposition, type FlowOutcome } from "./orchestration/flow-run.ts";
113
- import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
118
+ import { type RosterMember, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
119
+ import { agentAttemptIdentity, type AgentAttemptIdentity } from "./orchestration/agent-attempt.ts";
120
+ import { snapshotStrategyModels, strategyLegMetadata, type StrategyLegMetadata, type StrategyModels } from "./ui/strategy-leg.ts";
114
121
  import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
115
122
  import { withResultRefs } from "./orchestration/sdk.ts";
116
123
  import { knownParams, strategyNames } from "./orchestration/strategy.ts";
117
124
  import { compactMemberResult } from "./orchestration/render.ts";
118
125
  import { sumUsage } from "./orchestration/reducers.ts";
119
126
  import type { AgentResult, FailureKind } from "./orchestration/types.ts";
120
- import { isThinkingLevel, type ThinkingLevel } from "./core/types.ts";
127
+ import { isThinkingLevel } from "./core/types.ts";
121
128
  import { snapshotOrchestrationParticipants, type OrchestrationLaunchSnapshot } from "./ui/orchestration-launch.ts";
122
129
  import { SessionResults } from "./extension/results.ts";
123
130
  import { readArchivedBody, resultReceiptHint, type ResultReceipt } from "./results/retrieval.ts";
@@ -144,7 +151,7 @@ import { shortModel } from "./tools/delegate.ts";
144
151
  import { formatInbox } from "./tools/intercom.ts";
145
152
  import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
146
153
  import { AgentOverlay } from "./ui/agent-overlay.ts";
147
- import { type AddNodeInput, type AgentNode, type AgentNodePatch, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, isWakeSource, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
154
+ import { type AddNodeInput, type AgentNode, type AgentNodePatch, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, isNonAgentNode, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
148
155
  import { WakeRows, type WakeRowChange } from "./ui/wake-rows.ts";
149
156
  import { LiveClock } from "./ui/live-clock.ts";
150
157
  import { filterModels, ModelPicker, orderModelRefs, type ModelPickerMetadata } from "./ui/model-picker.ts";
@@ -263,7 +270,7 @@ export function inFlightAgentCount(nodes: ReadonlyArray<{ id: string; parentId?:
263
270
  }
264
271
  let count = 0;
265
272
  for (const node of nodes) {
266
- if (node.status === "running" && node.kind !== "delegate" && !isWakeSource(node) && !parents.has(node.id)) count++;
273
+ if (node.status === "running" && node.kind !== "delegate" && !isNonAgentNode(node) && !parents.has(node.id)) count++;
267
274
  }
268
275
  return count;
269
276
  }
@@ -310,15 +317,28 @@ export interface PiPersonaOptions {
310
317
  exocomArgv?: readonly string[];
311
318
  }
312
319
 
313
- export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions = {}): void {
320
+ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions = {}): void {
321
+ // Loaded-extension capabilities must be discoverable even in a real delegated leg. This is a
322
+ // local host contract, not settings/file inference; disabled supervisors explicitly deny use.
323
+ const bridgeMode = isBridgeMode();
324
+ const role = bridgeMode || process.env.PI_PERSONA_LEG === "1" ? "leg" : resolveConfig(process.env).disabled ? "disabled" : "supervisor";
325
+ installCompanionPresence(pi, role);
326
+ if (role === "leg") {
327
+ const inherited = inheritedMemoryPersona(process.env[MEMORY_PERSONA_ENV]);
328
+ if (inherited !== undefined) {
329
+ const owner = installMemoryOwner(pi, () => inherited.persona);
330
+ pi.on("session_start", (_event, ctx) => owner.start(ctx.sessionManager)());
331
+ pi.on("session_shutdown", () => owner.stop());
332
+ }
333
+ }
314
334
  // Cross-process broker (v0.5, spec B3): a child spawned with `PI_PERSONA_BUS` set is a
315
335
  // broker-connected sub-agent, not a supervisor — load ONLY the bridge (comm-plane tools +
316
- // inbound follow-ups) and skip the entire persona/delegate/orchestration surface. Checked
317
- // before anything else (even config resolution) so bridge mode can never be shadowed by
336
+ // inbound follow-ups) and skip the entire persona/delegate/orchestration surface. Checked
337
+ // after the lean companion responders and before the main disabled guard, so it cannot be shadowed by
318
338
  // the fork-bomb guard's `PI_PERSONA_DISABLE=1` short-circuit below, or by any other early
319
339
  // return. `PI_PERSONA_BUS` unset (the default, and every existing test) ⇒ this branch never
320
340
  // runs — zero behavior change.
321
- if (isBridgeMode()) {
341
+ if (bridgeMode) {
322
342
  pi.on("session_start", (_event, ctx) => installBridge(pi, ctx));
323
343
  return;
324
344
  }
@@ -568,7 +588,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
568
588
 
569
589
  // The unified live tree of every in-flight agent — strategy cores, delegate
570
590
  // sub-agents, dynamic specialists — rendered as one sticky widget above the input.
571
- const agentTree = new AgentTree();
591
+ const agentTree = new AgentTree();
592
+ const companionActivities = installActivityIngress(pi, agentTree);
593
+ const memoryOwner = installMemoryOwner(pi, () => controller.activePersona?.name ?? null);
572
594
  const wakeRows = new WakeRows();
573
595
  // Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
574
596
  // session-scoped and starts only after persona + exocom reconciliation in session_start.
@@ -645,7 +667,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
645
667
  const projection = JSON.stringify(agent);
646
668
  if (lastAgentProjection.get(node.id) === projection) return;
647
669
  lastAgentProjection.set(node.id, projection);
648
- producer.publishAgentUpdated(node.id, agent);
670
+ // Updates are patches: an omitted field would retain the previous attempt's model.
671
+ // The existing string wire contract uses an empty model to mean explicitly unknown.
672
+ producer.publishAgentUpdated(node.id, node.model === undefined ? { ...agent, model: "" } : agent);
649
673
  }
650
674
  function refreshSteeredAgentTelemetry(): void {
651
675
  if (!telemetry || steerRegistry.size === 0) return;
@@ -659,7 +683,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
659
683
  if (!producer) return;
660
684
  // Wake rows (timers, monitors) are not agents, and their labels are free text the telemetry
661
685
  // contract never persists: they stay on the local widget and never reach the bus.
662
- if ((change.type === "added" || change.type === "updated") && isWakeSource(change.node)) return;
686
+ if ((change.type === "added" || change.type === "updated") && isNonAgentNode(change.node)) return;
663
687
  if (change.type === "added") {
664
688
  lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
665
689
  producer.publishAgentAdded(telemetryAgent(change.node));
@@ -669,15 +693,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
669
693
  publishAgentProjection(change.node);
670
694
  return;
671
695
  }
672
- if (change.type === "removed") {
673
- for (const node of change.nodes.filter((n) => !isWakeSource(n))) {
696
+ if (change.type === "removed") {
697
+ for (const node of change.nodes.filter((n) => !isNonAgentNode(n))) {
674
698
  lastAgentProjection.delete(node.id);
675
699
  const status = node.status === "running" ? "stopped" : telemetryStatus(node);
676
700
  producer.publish("agent.removed", { id: node.id, status });
677
701
  }
678
- return;
679
- }
680
- lastAgentProjection.clear();
702
+ return;
703
+ }
704
+ if (change.nodes.every(isNonAgentNode)) return;
705
+ lastAgentProjection.clear();
681
706
  producer.publish("agent.cleared", {});
682
707
  }
683
708
  agentTree.onChange(publishAgentTreeChange);
@@ -766,7 +791,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
766
791
  /** The "N agents" status answers how many agents are working, so a tree holding only wake rows
767
792
  * (timers, monitors) publishes none, while the widget lines still show them. */
768
793
  function agentCountStatus(nodes: readonly AgentNode[]): string | undefined {
769
- const agents = nodes.filter((node) => !isWakeSource(node));
794
+ const agents = nodes.filter((node) => !isNonAgentNode(node));
770
795
  return agents.length === 0 ? undefined : String(inFlightAgentCount(agents));
771
796
  }
772
797
  function renderAgentWidget(): void {
@@ -920,8 +945,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
920
945
  return undefined;
921
946
  }
922
947
  };
923
- const persist = (name: string | undefined): void => {
924
- if (!config.persist) return;
948
+ const persist = (name: string | undefined): void => {
949
+ // An explicit selection revokes the old companion scope even when persistence is off.
950
+ // Worker runs keep their frozen owners; only the local activity projection is reset.
951
+ if (!disposed && lastCtx) companionActivities.start(lastCtx);
952
+ if (!config.persist) return;
925
953
  try {
926
954
  savedSettings = patchPersonaSettings(settingsFile, { lastPersona: name ?? null });
927
955
  } catch (error) {
@@ -1099,7 +1127,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1099
1127
  return false; // keep current, but let the controller report that the switch failed
1100
1128
  }
1101
1129
  },
1102
- setStatus: (text) => {
1130
+ setStatus: (text) => {
1131
+ if (!disposed) memoryOwner.changed();
1103
1132
  try {
1104
1133
  lastCtx?.ui.setStatus("persona", text === undefined ? undefined : compactInlineText(text, { maxChars: 80 }));
1105
1134
  } catch {
@@ -1308,7 +1337,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1308
1337
  announceAsyncRunSettlement(
1309
1338
  run,
1310
1339
  lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
1311
- (settled) => completionNotifier.notify(settled),
1340
+ (settled) => { if (settled.delivery !== "companion") completionNotifier.notify(settled); },
1312
1341
  lastCtx?.hasUI && lastCtx.mode === "tui" ? (failed) => {
1313
1342
  const model = failed.result?.modelUsed ?? failed.model;
1314
1343
  const durationMs = failed.startedAt !== undefined && failed.settledAt !== undefined
@@ -1496,7 +1525,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1496
1525
  /* cosmetic — the status is best-effort */
1497
1526
  }
1498
1527
  };
1499
- const buildEngine: typeof innerBuildEngine = (signal, onProgress, engOpts) => {
1528
+ const buildEngine: typeof innerBuildEngine = (signal, onProgress, engOpts) => {
1500
1529
  const engine = innerBuildEngine(signal, onProgress, engOpts);
1501
1530
  return {
1502
1531
  run: async (spec, progress, sig, steer) => {
@@ -1506,7 +1535,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1506
1535
  return result;
1507
1536
  },
1508
1537
  };
1509
- };
1538
+ };
1510
1539
 
1511
1540
  /**
1512
1541
  * Every participant a run really spawns — roster members (inline ones included) plus the
@@ -1743,77 +1772,25 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1743
1772
  };
1744
1773
  }
1745
1774
 
1746
- // Each core's model beside its name, in the ONE precedence the picker saved under: the
1747
- // participant's own assignment key (a role member's `agent#hash`, else the bare agent key) →
1748
- // the agent's declared default → the session model. An inline model is already the participant's
1749
- // explicit engine pin and wins before any saved assignment.
1750
- function coreModel(ctx: ExtensionContext, agent: string, assignmentKey: string = agent, participant?: ModelParticipant | null): string | undefined {
1751
- if (participant === null) return undefined; // a short-hint collision cannot identify one role safely
1752
- if (participant?.source === "inline" && participant.model) return participant.model;
1753
- const persona = controller.activePersona?.name;
1754
- const configured = persona ? personaModels(personaConfigs, persona) : {};
1755
- return configured[assignmentKey] ?? configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
1756
- }
1757
- function coreTelemetryMeta(ctx: ExtensionContext, agent: string, assignmentKey?: string, participant?: ModelParticipant | null): { agent: string; model?: string; thinking?: ThinkingLevel } {
1758
- const model = coreModel(ctx, agent, assignmentKey, participant);
1759
- // The member's explicit spec-level effort rides the SAME participant channel as the model, so
1760
- // every strategy-seeded row (queued seed, live re-add, flow phase) names its effort from the
1761
- // first frame — the delegate launch rows' own rule. An ambiguous/null participant identifies
1762
- // no single lens, so it carries nothing, and absence renders nothing.
1763
- return { agent, ...(model ? { model } : {}), ...(participant?.thinking ? { thinking: participant.thinking } : {}) };
1764
- }
1765
- function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent, assignmentKey?: string, participant?: ModelParticipant | null): string {
1766
- const short = shortModel(coreModel(ctx, agent, assignmentKey, participant));
1775
+ /** Capture at engine construction, after setup: queued legs retain that run's assignments. */
1776
+ function currentStrategyModels(ctx: ExtensionContext): StrategyModels {
1777
+ const persona = controller.activePersona?.name;
1778
+ return snapshotStrategyModels({ agents, ...(persona ? { assigned: personaModels(personaConfigs, persona) } : {}),
1779
+ ...(ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {}) });
1780
+ }
1781
+ function coreLabel(identity: AgentAttemptIdentity, key: string, metadata: StrategyLegMetadata): string {
1782
+ const short = shortModel(metadata.model);
1767
1783
  // `key` is the disambiguated node id (`agent` for a solo member, `agent · HINT` for a
1768
1784
  // roster-role one) — display that + what the core is FOR + the model, so a watcher reads the
1769
1785
  // council as roles rather than names, and three `reviewer` lenses still read distinctly.
1770
- // A roster-ROLE member already carries its lens in `key` (`agent · HINT`) and its standing
1771
- // purpose would repeat it — but a plain repeat (`melchior#2`) is the same core twice and
1772
- // still wants its lens, so key off the role separator, not off `key !== agent`.
1773
- const purpose = key.includes(" · ") ? undefined : corePurpose(agent);
1786
+ // A role member already carries its lens in the display key. The full attempt identity,
1787
+ // rather than that shortened label, decides whether a standing purpose would repeat it.
1788
+ const purpose = identity.role ? undefined : corePurpose(identity.agent);
1774
1789
  // `key` embeds the member's role HINT (derived from its role prompt), and `short` embeds a
1775
1790
  // model ref — both are dynamic, so the whole row is collapsed to one escape-free line.
1776
1791
  return compactInlineText([key, purpose, short].filter(Boolean).join(" · "), { maxChars: 120 });
1777
1792
  }
1778
1793
 
1779
- /** The assignment key for a tree node key when no participant was seeded under it: strip the lens
1780
- * hint and the occurrence suffix, leaving the bare agent — the legacy key shape, which always
1781
- * resolves (a role-less agent is exactly how those assignments were saved). */
1782
- function bareAssignmentKey(nodeKey: string): string {
1783
- return (nodeKey.split(" · ")[0] ?? nodeKey).replace(/#\d+$/, "");
1784
- }
1785
-
1786
- /** Map every picker participant to the SDK's display base. A short role hint is not unique when
1787
- * different role prompts share it, so ambiguous nodes omit the model rather than borrowing
1788
- * another lens' assignment. Repeated SDK keys (`#2`, …) resolve through their base and share the
1789
- * same participant. */
1790
- function participantByNode(participants: ModelParticipant[]): Map<string, ModelParticipant | null> {
1791
- const groups = new Map<string, Map<string, ModelParticipant>>();
1792
- for (const participant of participants) {
1793
- const base = participant.role ? `${participant.agent} · ${roleHint(participant.role)}` : participant.agent;
1794
- const group = groups.get(base) ?? new Map<string, ModelParticipant>();
1795
- group.set(participant.key, participant);
1796
- groups.set(base, group);
1797
- }
1798
- const out = new Map<string, ModelParticipant | null>();
1799
- for (const [base, group] of groups) {
1800
- if (group.size !== 1) {
1801
- out.set(base, null);
1802
- continue;
1803
- }
1804
- const participant = group.values().next().value as ModelParticipant | undefined;
1805
- if (participant) out.set(base, participant);
1806
- }
1807
- return out;
1808
- }
1809
-
1810
- function participantForNode(nodeParticipants: ReadonlyMap<string, ModelParticipant | null>, nodeKey: string): ModelParticipant | null | undefined {
1811
- const exact = nodeParticipants.get(nodeKey);
1812
- if (exact !== undefined) return exact;
1813
- const base = nodeKey.replace(/#\d+$/, "");
1814
- return base === nodeKey ? undefined : nodeParticipants.get(base);
1815
- }
1816
-
1817
1794
  /** Surface the SDK's main-model recovery in the UI: a broken member is re-run on the session's own
1818
1795
  * model rather than dropped from the council, and the operator deserves to see that switch.
1819
1796
  * Strictly cosmetic — a toast that throws must never corrupt a run's result. */
@@ -1833,31 +1810,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1833
1810
  // The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
1834
1811
  // `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
1835
1812
  // cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
1836
- function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map(), tracked?: TrackedRun) {
1813
+ function strategyTreeDeps(rootId: string, models: StrategyModels, tracked?: TrackedRun) {
1837
1814
  return {
1838
- onAgentStart: (agent: string, abort: () => void, key?: string) => {
1839
- stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
1815
+ onAgentStart: (_agent: string, abort: () => void, key: string) => {
1816
+ stopRegistry.set(`${rootId}/${key}`, abort);
1817
+ },
1818
+ onAgentSteerable: (_agent: string, steer: SteerFn, key: string) => {
1819
+ steerRegistry.set(`${rootId}/${key}`, steer);
1840
1820
  },
1841
- onAgentSteerable: (agent: string, steer: SteerFn, key?: string) => {
1842
- steerRegistry.set(`${rootId}/${key ?? agent}`, steer);
1843
- },
1844
- onAgentStatus: (agent: string, st: AgentStatus, result?: AgentResult, key?: string) => {
1845
- const nodeKey = key ?? agent;
1846
- const id = `${rootId}/${nodeKey}`;
1821
+ onAgentStatus: (_agent: string, st: AgentStatus, result: AgentResult | undefined, nodeKey: string, identity: AgentAttemptIdentity) => {
1822
+ const id = `${rootId}/${nodeKey}`;
1823
+ const metadata = strategyLegMetadata(identity, models, result);
1847
1824
  if (st === "running") {
1848
1825
  // detail "" clears the seeded "queued" marker — this core is actually live now.
1849
1826
  // Stamp the clock here too: the seed's stamp (from AgentTree.add's upsert) is queue
1850
1827
  // time, not run time, so a queued leg would otherwise show elapsed time it never
1851
1828
  // spent running and could raise a false stall badge once it goes live.
1852
1829
  const now = Date.now();
1853
- const participant = participantForNode(nodeParticipants, nodeKey);
1854
- const assignmentKey = participant?.key ?? bareAssignmentKey(nodeKey);
1855
- 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 });
1830
+ agentTree.add({ id, label: coreLabel(identity, nodeKey, metadata), parentId: rootId, status: "running", kind: "subagent", ...metadata, detail: "", startedAt: now, lastAdvanceAt: now });
1856
1831
  return;
1857
1832
  }
1858
1833
  stopRegistry.delete(id);
1859
1834
  steerRegistry.delete(id);
1860
- const patch: { status: AgentNodeStatus; detail?: string; output?: string; thinkingEffective?: ThinkingLevel } = { status: st };
1835
+ const patch: AgentNodePatch = { status: st, ...metadata };
1861
1836
  if (result) {
1862
1837
  const u = formatUsage(result.usage);
1863
1838
  if (u) patch.detail = u;
@@ -1866,12 +1841,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1866
1841
  // stays up (siblings still going) the row honestly reads `xh→h`; once the run ends the
1867
1842
  // removal makes it a one-frame flash. effortTag renders the divergence only when the
1868
1843
  // requested side exists and differs, so an unstamped request shows nothing extra.
1869
- if (result.thinkingEffective) patch.thinkingEffective = result.thinkingEffective;
1870
- }
1871
- agentTree.update(id, patch);
1844
+ }
1845
+ agentTree.add({ id, label: coreLabel(identity, nodeKey, metadata), parentId: rootId, ...patch });
1872
1846
  },
1873
- onAgentProgress: (agent: string, p: AgentProgress, key?: string) => {
1874
- const id = `${rootId}/${key ?? agent}`;
1847
+ onAgentProgress: (_agent: string, p: AgentProgress, key: string) => {
1848
+ const id = `${rootId}/${key}`;
1875
1849
  if (p.toolEvent) publishAgentTool(id, p.toolEvent);
1876
1850
  const now = Date.now();
1877
1851
  agentTree.update(id, progressPatch(p, now));
@@ -1916,7 +1890,6 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1916
1890
  const label = resolveStrategyName(orch) ?? "strategy";
1917
1891
  const roster = resolveOrchestrationMembers(orch, teams);
1918
1892
  const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
1919
- const participantsByNode = participantByNode(participants);
1920
1893
  results.activate(ctx);
1921
1894
  const { rootId, signal, live } = visibleRoot(`${idPrefix}:${label}`, tracked);
1922
1895
  const capture = results.bind(rootId);
@@ -1928,25 +1901,26 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1928
1901
  agentTree.add({ id: rootId, label, status: "running", kind: "council", ...live });
1929
1902
  try {
1930
1903
  await ensurePersonaModels(ctx, participants, signal, rootId, tracked);
1931
- if (signal.aborted) {
1904
+ if (signal.aborted) {
1932
1905
  agentTree.update(rootId, { status: "stopped" });
1933
1906
  const cancelled: AgentResult = { agent: label, output: "cancelled before orchestration started", usage: emptyUsage(), ok: false, error: "cancelled", failureKind: "abort" };
1934
- return finish(cancelled);
1935
- }
1907
+ return finish(cancelled);
1908
+ }
1909
+ const models = currentStrategyModels(ctx);
1910
+ const engine = buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true });
1936
1911
  // Seed the whole roster at once (cores show by name immediately); "queued" until the
1937
1912
  // engine actually starts each one — an honest view under the concurrency limit.
1938
1913
  const seedKeys = rosterNodeKeys(roster);
1939
- roster.forEach((m, i) => {
1940
- const a = rosterSpec(m).agent;
1941
- const key = seedKeys[i] ?? a;
1942
- const participant = participantForNode(participantsByNode, key);
1943
- const assignmentKey = participant?.key ?? bareAssignmentKey(key);
1944
- 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" });
1914
+ roster.forEach((m, i) => {
1915
+ const identity = agentAttemptIdentity(rosterSpec(m));
1916
+ const key = seedKeys[i] ?? identity.agent;
1917
+ const metadata = strategyLegMetadata(identity, models);
1918
+ agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(identity, key, metadata), parentId: rootId, status: "running", kind: "subagent", ...metadata, detail: "queued" });
1945
1919
  });
1946
1920
  // The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
1947
1921
  // cooperatively between rounds, so an aborted run stops convening instead of running every
1948
1922
  // remaining round against an already-cancelled engine (docs/STRATEGIES.md).
1949
- 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), onAgentResult, ...strategySpawnGate(), ...modelFallbackDep(ctx) });
1923
+ const result = await runPersonaStrategy(orch, task, { engine, teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), signal, ...strategyTreeDeps(rootId, models, tracked), onAgentResult, ...strategySpawnGate(), ...modelFallbackDep(ctx) });
1950
1924
  agentTree.update(rootId, { status: signal.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
1951
1925
  return finish(result ?? { agent: label, output: "", ok: false, usage: emptyUsage(), error: "No strategy result", failureKind: "contract" });
1952
1926
  } catch (error) {
@@ -2113,34 +2087,32 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2113
2087
  if (result?.error) patch.detail = result.error;
2114
2088
  agentTree.update(`${flowRoot}/${id}`, patch);
2115
2089
  },
2116
- runPhase: async ({ phase, task, resultRefs }) => {
2090
+ runPhase: async ({ phase, task, resultRefs }) => {
2117
2091
  const pid = `${flowRoot}/${phase.id}`;
2118
2092
  const plan = phasePlans.get(phase.id) ?? { members: [], participants: [] };
2119
2093
  const roster = plan.members;
2120
2094
  const seedKeys = rosterNodeKeys(roster);
2121
- const participantsByNode = participantByNode(plan.participants);
2122
- roster.forEach((m, i) => {
2123
- const a = rosterSpec(m).agent;
2124
- const key = seedKeys[i] ?? a;
2125
- const participant = participantForNode(participantsByNode, key);
2126
- const assignmentKey = participant?.key ?? bareAssignmentKey(key);
2127
- 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" });
2095
+ const models = currentStrategyModels(ctx);
2096
+ // Pin reference grants at the engine seam, beside the same frozen model selection
2097
+ // used for phase seeds and live attempts. Strategies cannot enlarge these grants.
2098
+ const phaseEngine = spec.handoff === "reference"
2099
+ ? withResultRefs(buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }), resultRefs)
2100
+ : buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true });
2101
+ roster.forEach((m, i) => {
2102
+ const identity = agentAttemptIdentity(rosterSpec(m));
2103
+ const key = seedKeys[i] ?? identity.agent;
2104
+ const metadata = strategyLegMetadata(identity, models);
2105
+ agentTree.add({ id: `${pid}/${key}`, label: coreLabel(identity, key, metadata), parentId: pid, status: "running", kind: "subagent", ...metadata, detail: "queued" });
2128
2106
  });
2129
2107
  const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
2130
2108
  if (phase.roster) orch.roster = phase.roster;
2131
- // Reference handoff (trusted grant wiring): the phase's legs get EXACTLY the direct
2132
- // upstream refs flow-run threaded through `runPhase` - pinned at the engine seam, so
2133
- // a strategy can neither enlarge nor smuggle refs (the SDK strips its own attempts).
2134
- const phaseEngine = spec.handoff === "reference"
2135
- ? withResultRefs(buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }), resultRefs)
2136
- : buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true });
2137
2109
  const r = await runPersonaStrategy(orch, task, {
2138
2110
  engine: phaseEngine,
2139
2111
  teams,
2140
2112
  limits: RUN_LIMITS,
2141
2113
  ...sessionModelDep(ctx),
2142
2114
  signal, // cooperative per-round abort inside the phase's strategy, not just the engine
2143
- ...strategyTreeDeps(ctx, pid, participantsByNode, tracked),
2115
+ ...strategyTreeDeps(pid, models, tracked),
2144
2116
  onAgentResult: (result, key) => { result.resultReceipt = capture(`phase:${phase.id}:leg:${key}`, result); },
2145
2117
  ...strategySpawnGate(),
2146
2118
  ...modelFallbackDep(ctx),
@@ -2378,7 +2350,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2378
2350
  return lines.join("\n");
2379
2351
  }
2380
2352
 
2381
- const hookHost: HookHost = {
2353
+ const hookHost: HookHost = {
2354
+ companionActivities,
2355
+ memoryOwner,
2382
2356
  identity,
2383
2357
  results,
2384
2358
  get config() { return config; },
@@ -2463,7 +2437,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2463
2437
 
2464
2438
  // ONE launch pool for every background run: a delegate leg, a `council` and a `flow` each take a slot
2465
2439
  // (maxConcurrency). The strategy SDK's own per-run semaphore gates the legs INSIDE a council or flow.
2466
- const launchPool = new Semaphore(RUN_LIMITS.maxConcurrency);
2440
+ const launchPool = new Semaphore(RUN_LIMITS.maxConcurrency);
2441
+ installMindWorker(pi, {
2442
+ context: () => disposed ? undefined : lastCtx,
2443
+ canLaunch: () => {
2444
+ const caps = controller.capabilities;
2445
+ if (caps && (!canFanOut(caps) || !canCallTool(caps, "memory"))) return "persona denies memory or delegation tools";
2446
+ return isAllowed("mind", controller.activePersona?.delegate, config.delegateDefaultAllow) || "persona denies delegation to mind; the delegate target rules must allow mind";
2447
+ },
2448
+ tracker, tree: agentTree, slots: launchPool, stopRegistry, steerRegistry, buildEngine, startPeek,
2449
+ });
2467
2450
  const backgroundRuns = createBackgroundRuns({
2468
2451
  tracker,
2469
2452
  agentTree,
@@ -0,0 +1,15 @@
1
+ /** Exact metadata for one engine attempt; display hints are never participant identities. */
2
+ import type { ThinkingLevel } from "../core/types.ts";
3
+
4
+ export interface AgentAttemptIdentity {
5
+ readonly agent: string;
6
+ readonly role?: string;
7
+ readonly model?: string;
8
+ readonly thinking?: ThinkingLevel;
9
+ }
10
+
11
+ /** Copy primitives only: no task, tool grants, result references or mutable caller object. */
12
+ export function agentAttemptIdentity(spec: AgentAttemptIdentity): AgentAttemptIdentity {
13
+ return Object.freeze({ agent: spec.agent, ...(spec.role !== undefined ? { role: spec.role } : {}),
14
+ ...(spec.model !== undefined ? { model: spec.model } : {}), ...(spec.thinking !== undefined ? { thinking: spec.thinking } : {}) });
15
+ }
@@ -161,7 +161,10 @@ function buildPhaseTask(baseTask: string, spec: FlowSpec, phase: FlowPhase, upst
161
161
  parts.push(`\n--- ${id} ---`);
162
162
  parts.push(`status: ${r.ok ? "done" : "failed"}`);
163
163
  if (ref === undefined) {
164
- parts.push(`result reference unavailable: ${r.output.slice(0, 200) || "(no output)"}`);
164
+ const preview = r.output.slice(0, 200);
165
+ parts.push(preview
166
+ ? `result reference unavailable:\n${fenceUntrusted(preview)}`
167
+ : "result reference unavailable: (no output)");
165
168
  continue;
166
169
  }
167
170
  parts.push(`result ref: ${ref}`);
@@ -13,6 +13,7 @@ import { planModelRecovery } from "./model-retry.ts";
13
13
  import { mapWithConcurrency, Semaphore } from "./parallel.ts";
14
14
  import { aggregateResults, sumUsage } from "./reducers.ts";
15
15
  import { roleHint, type RosterMember } from "./roster.ts";
16
+ import { agentAttemptIdentity, type AgentAttemptIdentity } from "./agent-attempt.ts";
16
17
  import type { AgentResult, ModelRecovery } from "./types.ts";
17
18
  import { type ReducerResult, type VoteOpts, voteReduce } from "./voting.ts";
18
19
 
@@ -199,17 +200,17 @@ export interface SDKDeps {
199
200
  /** Per-agent lifecycle, for live UI. The result is passed on done/failed so the
200
201
  * UI can capture each agent's output/usage. `key` is a run-unique display id (the
201
202
  * `agent` name for a solo member, disambiguated by role/occurrence when the same agent
202
- * runs several times in one roster) — key the UI node by it, not by `agent`, or three
203
- * same-agent roster-role members collapse into one node. Falls back to `agent`. */
204
- onAgentStatus?: (agent: string, status: AgentStatus, result?: AgentResult, key?: string) => void;
203
+ * runs several times in one roster). The frozen `attempt` carries exact full-role/model/effort
204
+ * identity: never recover those fields by parsing this shortened display key. */
205
+ onAgentStatus?: (agent: string, status: AgentStatus, result: AgentResult | undefined, key: string, attempt: AgentAttemptIdentity) => void;
205
206
  /** One final logical leg after model recovery, never intermediate attempts. Storage is non-fatal. */
206
- onAgentResult?: (result: AgentResult, key: string) => void;
207
+ onAgentResult?: (result: AgentResult, key: string, attempt: AgentAttemptIdentity) => void;
207
208
  /** Per-agent streaming progress (rolling output), for live UI. See `onAgentStatus.key`. */
208
- onAgentProgress?: (agent: string, progress: AgentProgress, key?: string) => void;
209
+ onAgentProgress?: (agent: string, progress: AgentProgress, key: string, attempt: AgentAttemptIdentity) => void;
209
210
  /** Called as each agent starts with a handle to abort just that agent (for UI stop). */
210
- onAgentStart?: (agent: string, abort: () => void, key?: string) => void;
211
+ onAgentStart?: (agent: string, abort: () => void, key: string, attempt: AgentAttemptIdentity) => void;
211
212
  /** Called once an agent is live with a handle to steer it (in-process engine only). */
212
- onAgentSteerable?: (agent: string, steer: SteerFn, key?: string) => void;
213
+ onAgentSteerable?: (agent: string, steer: SteerFn, key: string, attempt: AgentAttemptIdentity) => void;
213
214
  /** Emitted immediately BEFORE a leg is re-run on the main model after a model-caused failure,
214
215
  * so live UI can say so while it happens (`key` is the same logical node as the attempt it
215
216
  * replaces). Purely cosmetic: a throw here is contained and can never discard the billed
@@ -303,8 +304,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
303
304
  // seam via `withResultRefs`, which runs BELOW this layer).
304
305
  const spec = requested.resultRefs === undefined ? requested : stripResultRefs(requested);
305
306
  const key = uiKeyFor(spec);
307
+ let currentAttempt = agentAttemptIdentity(spec);
306
308
  const finish = (result: AgentResult): AgentResult => {
307
- try { deps.onAgentResult?.(result, key); } catch { /* storage must not discard a completed leg */ }
309
+ try { deps.onAgentResult?.(result, key, currentAttempt); } catch { /* storage must not discard a completed leg */ }
308
310
  return result;
309
311
  };
310
312
  if (deps.canSpawn && !deps.canSpawn(spec.agent)) {
@@ -338,6 +340,8 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
338
340
  const detachRoot: Array<() => void> = [];
339
341
  let announcedStart = false;
340
342
  const attempt = async (aSpec: AgentRunSpec): Promise<AgentResult> => {
343
+ const identity = agentAttemptIdentity(aSpec);
344
+ currentAttempt = identity;
341
345
  const ac = current ?? new AbortController();
342
346
  current = ac;
343
347
  if (deps.signal) {
@@ -348,9 +352,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
348
352
  }
349
353
  if (!announcedStart) {
350
354
  announcedStart = true;
351
- deps.onAgentStart?.(aSpec.agent, () => current?.abort(), key);
355
+ deps.onAgentStart?.(aSpec.agent, () => current?.abort(), key, identity);
352
356
  }
353
- deps.onAgentStatus?.(aSpec.agent, "running", undefined, key);
357
+ deps.onAgentStatus?.(aSpec.agent, "running", undefined, key, identity);
354
358
  // A slot grant is not an engine start. Root or local cancellation in a lifecycle
355
359
  // callback must prevent the engine invocation, not merely hand it an aborted signal.
356
360
  if (ac.signal.aborted) {
@@ -366,9 +370,9 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
366
370
  try {
367
371
  result = await deps.engine.run(
368
372
  aSpec,
369
- onProgress ? (p) => onProgress(aSpec.agent, p, key) : undefined,
373
+ onProgress ? (p) => onProgress(aSpec.agent, p, key, identity) : undefined,
370
374
  ac.signal,
371
- deps.onAgentSteerable ? (steer) => deps.onAgentSteerable?.(aSpec.agent, steer, key) : undefined,
375
+ deps.onAgentSteerable ? (steer) => deps.onAgentSteerable?.(aSpec.agent, steer, key, identity) : undefined,
372
376
  );
373
377
  } catch (err) {
374
378
  // PER-LEG failure — contained, never rethrown: one blown engine call must not discard
@@ -385,7 +389,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
385
389
  };
386
390
  try {
387
391
  const first = await attempt(spec);
388
- deps.onAgentStatus?.(spec.agent, first.ok ? "done" : "failed", first, key);
392
+ deps.onAgentStatus?.(spec.agent, first.ok ? "done" : "failed", first, key, currentAttempt);
389
393
 
390
394
  // ── Main-only model recovery ──────────────────────────────────────────────
391
395
  // A model-caused failure (`provider`/`unknown-model`) gets exactly ONE re-run on the
@@ -427,7 +431,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
427
431
  // a fresh controller or spend another child slot after it has been stopped.
428
432
  if (deps.signal?.aborted || current?.signal.aborted) {
429
433
  const stopped = { ...abortedLeg(spec.agent), usage: first.usage };
430
- deps.onAgentStatus?.(spec.agent, "failed", stopped, key);
434
+ deps.onAgentStatus?.(spec.agent, "failed", stopped, key, currentAttempt);
431
435
  return stopped;
432
436
  }
433
437
  // The retry is a REAL attempt: admitted and charged like any other child, but inside
@@ -444,7 +448,7 @@ export function makeSDK(deps: SDKDeps): StrategySDK {
444
448
  modelRecovery: recovery,
445
449
  ...(!second.ok ? { error: `Initial model ${plan.from ?? "(unresolved)"} failed: ${first.error ?? first.failureKind ?? "unknown cause"}; main model ${plan.to} failed: ${second.error ?? second.failureKind ?? "unknown cause"}` } : {}),
446
450
  };
447
- deps.onAgentStatus?.(spec.agent, settled.ok ? "done" : "failed", settled, key);
451
+ deps.onAgentStatus?.(spec.agent, settled.ok ? "done" : "failed", settled, key, currentAttempt);
448
452
  return settled;
449
453
  } finally {
450
454
  for (const detach of detachRoot) detach();