@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Supervisor-side broker lifecycle (spec B1-B7): the ONE cross-process host every child-engine
3
+ * leg connects to. Started lazily on the first child-engine build; a failed bind is reported to
4
+ * the user ONCE, children built while it is failed spawn without a bus endpoint (so they never
5
+ * burn the connect backoff against a dead socket), and the next build retries.
6
+ */
7
+
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ import type { EngineAdapterBroker } from "../engine/adapter.ts";
12
+ import { type BrokerHost, startBrokerHost } from "../bus/broker/host.ts";
13
+ import type { InProcessBus } from "../bus/inproc.ts";
14
+
15
+ export interface SupervisorBrokerDeps {
16
+ bus: InProcessBus;
17
+ supervisorHandle: string;
18
+ /** Injected in tests. */
19
+ startHost?: typeof startBrokerHost;
20
+ /** User-facing warning (ctx.ui.notify "warning" when a TUI is present, stderr otherwise). */
21
+ warn: (message: string) => void;
22
+ }
23
+
24
+ /** Cross-process `contact_peer` roster (spec B7): scopes `brokerPeers` — the process-wide,
25
+ * pre-spawn registry keyed by handle (populated in `SupervisorBroker.adapterDeps`'s `register`,
26
+ * below) — to the SAME per-engine group as the caller `self`, mirroring `engine/inproc.ts`'s
27
+ * per-engine-instance `peerLabels` map. `self`'s OWN recorded group is the source of truth
28
+ * here, NOT the wire's `group` argument the host would otherwise pass: the child's env
29
+ * carries no group (spec B6, the wire `register` frame stays minimal), so every wire group
30
+ * is "" and scoping by it would either always come back empty (this scope) or leak every
31
+ * concurrent run's peers into one flat list (the host's own default `group=""` scoping).
32
+ * `self` not found (not registered with `peers: true`) ⇒ empty roster, never a leak.
33
+ * Exported for direct unit/integration testing — `extension.ts`'s activation closure itself
34
+ * isn't a testable unit. */
35
+ export function listPeersForGroup(brokerPeers: ReadonlyMap<string, { label: string; group: string }>, self: string): Array<{ handle: string; label: string }> {
36
+ const g = brokerPeers.get(self)?.group;
37
+ if (g === undefined) return [];
38
+ return [...brokerPeers.entries()]
39
+ .filter(([handle, p]) => p.group === g && handle !== self)
40
+ .map(([handle, p]) => ({ handle, label: p.label }));
41
+ }
42
+
43
+ export class SupervisorBroker {
44
+ readonly peers = new Map<string, { label: string; group: string }>();
45
+ private hostRef: BrokerHost | undefined;
46
+ private promise: Promise<BrokerHost> | undefined;
47
+ private lastError: string | undefined;
48
+ private warned = false;
49
+ private readonly expected = new Set<string>();
50
+ private readonly preHostSteers = new Map<string, string[]>();
51
+ private readonly deps: SupervisorBrokerDeps;
52
+
53
+ constructor(deps: SupervisorBrokerDeps) {
54
+ this.deps = deps;
55
+ }
56
+
57
+ get host(): BrokerHost | undefined {
58
+ return this.hostRef;
59
+ }
60
+
61
+ get error(): string | undefined {
62
+ return this.lastError;
63
+ }
64
+
65
+ /** The lifecycle state `adapterDeps`, `steerFrame`, and `doctorLine` each branch on, named
66
+ * once so they can never read it as three different ad hoc conditions. */
67
+ private state(): "idle" | "starting" | "up" | "failed" {
68
+ if (this.hostRef) return "up";
69
+ if (this.lastError !== undefined) return "failed";
70
+ if (this.promise) return "starting";
71
+ return "idle";
72
+ }
73
+
74
+ /** Start the host once per endpoint attempt; retried on the next call after a failure. */
75
+ ensure(endpoint: string): void {
76
+ if (this.promise) return;
77
+ if (process.platform !== "win32") {
78
+ try {
79
+ mkdirSync(dirname(endpoint), { recursive: true }); // POSIX sockets are filesystem paths
80
+ } catch {
81
+ /* a failed mkdir surfaces as a listen error below */
82
+ }
83
+ }
84
+ const start = this.deps.startHost ?? startBrokerHost;
85
+ this.promise = start({
86
+ bus: this.deps.bus,
87
+ supervisorHandle: this.deps.supervisorHandle,
88
+ endpoint,
89
+ listPeersFor: (_group, self) => listPeersForGroup(this.peers, self),
90
+ });
91
+ this.promise.then(
92
+ (h) => {
93
+ this.hostRef = h;
94
+ this.lastError = undefined;
95
+ this.warned = false;
96
+ for (const handle of this.expected) h.expect(handle);
97
+ for (const [handle, texts] of this.preHostSteers) for (const text of texts) h.steer(handle, text);
98
+ this.preHostSteers.clear();
99
+ },
100
+ (err) => {
101
+ this.promise = undefined; // a later build retries
102
+ this.lastError = err instanceof Error ? err.message : String(err);
103
+ const dropped = [...this.preHostSteers.values()].reduce((n, texts) => n + texts.length, 0);
104
+ this.preHostSteers.clear();
105
+ if (!this.warned) {
106
+ this.warned = true;
107
+ this.deps.warn(
108
+ `pi-persona: child-agent bus unavailable — ${this.lastError}; child legs run without live steer/contact until it starts${dropped > 0 ? `; ${dropped} queued steer${dropped === 1 ? "" : "s"} ${dropped === 1 ? "was" : "were"} dropped` : ""}`,
109
+ );
110
+ }
111
+ },
112
+ );
113
+ }
114
+
115
+ /** The adapter-facing deps, or undefined while the last start failed (children spawn bus-less). */
116
+ adapterDeps(endpoint: string): EngineAdapterBroker | undefined {
117
+ const failedBefore = this.state() === "failed";
118
+ this.ensure(endpoint);
119
+ if (failedBefore) return undefined;
120
+ return {
121
+ endpoint,
122
+ register: (info) => {
123
+ this.deps.bus.register(info.handle);
124
+ this.expected.add(info.handle);
125
+ if (info.peers) this.peers.set(info.handle, { label: info.label ?? info.handle, group: info.group ?? "" });
126
+ this.hostRef?.expect(info.handle);
127
+ },
128
+ unregister: (handle) => {
129
+ this.expected.delete(handle);
130
+ this.preHostSteers.delete(handle);
131
+ this.peers.delete(handle);
132
+ this.deps.bus.unregister(handle);
133
+ this.hostRef?.forget(handle);
134
+ },
135
+ steerFrame: (handle, text) => {
136
+ if (this.hostRef) return this.hostRef.steer(handle, text);
137
+ if (this.state() === "failed" || !this.expected.has(handle) || !text.trim()) return false;
138
+ const queued = this.preHostSteers.get(handle) ?? [];
139
+ queued.push(text);
140
+ this.preHostSteers.set(handle, queued);
141
+ return true;
142
+ },
143
+ hasPendingAskFrom: (handle) => this.deps.bus.hasPendingAskFrom(handle),
144
+ };
145
+ }
146
+
147
+ doctorLine(): string {
148
+ // Failed is reported on its own (spec §4.7: `broker: failed — <reason>`) rather than folded
149
+ // into the "on — …" family: there is no host and no connected-children count to report while
150
+ // down, so "broker: on — failed — …" was a confusing double dash for a state that isn't "on".
151
+ if (this.state() === "failed") return `broker: failed — ${this.lastError}`;
152
+ let status: string;
153
+ switch (this.state()) {
154
+ case "up":
155
+ status = `endpoint ${this.hostRef?.endpoint}`;
156
+ break;
157
+ case "starting":
158
+ status = "(starting…)";
159
+ break;
160
+ default:
161
+ status = "(not started — no child-engine build yet)";
162
+ }
163
+ return `broker: on — ${status}, connected children: ${this.hostRef?.connectedHandles().length ?? 0}`;
164
+ }
165
+
166
+ async close(): Promise<void> {
167
+ if (this.promise) {
168
+ try {
169
+ const h = await this.promise;
170
+ await h.close();
171
+ } catch {
172
+ /* best-effort — never block shutdown on a broker teardown error */
173
+ }
174
+ }
175
+ this.hostRef = undefined;
176
+ this.promise = undefined;
177
+ this.peers.clear();
178
+ this.expected.clear();
179
+ this.preHostSteers.clear();
180
+ }
181
+ }
@@ -0,0 +1,14 @@
1
+ /** Resolve host-local facts outside the pure clock formatter. */
2
+ import { buildClockSnapshot } from "../core/time.ts";
3
+
4
+ export function readClockSnapshot(now: number): string | undefined {
5
+ const date = new Date(now);
6
+ if (!Number.isFinite(date.getTime())) return undefined;
7
+ let timeZone = "local timezone";
8
+ try {
9
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || timeZone;
10
+ } catch {
11
+ // The numeric offset still identifies local time when an IANA name is unavailable.
12
+ }
13
+ return buildClockSnapshot(now, { timeZone, offsetMinutes: -date.getTimezoneOffset() });
14
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Engine construction: in-process vs child, worktree isolation, MCP-child routing, provider fallback.
3
+ */
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { AgentConfig } from "../agents/agent.ts";
7
+ import { type ContractDef, DEFAULT_CONTRACT, installedContractNames } from "../core/contract.ts";
8
+ import type { RunLimits } from "../core/capabilities.ts";
9
+ import type { PiPersonaConfig } from "../core/config.ts";
10
+ import { isThinkingLevel } from "../core/types.ts";
11
+ import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
12
+ import type { PersonaController, PersonaHost } from "../persona/controller.ts";
13
+ import { type EngineAdapterBroker, type EngineAdapterDeps, makeEngine } from "../engine/adapter.ts";
14
+ import { captureStatus, diffStatus, findGitRoot, renderChangeReport } from "../engine/change-report.ts";
15
+ import { withModelFallback } from "../engine/fallback.ts";
16
+ import { captureWorktreeArtifact, defaultGitExec, type GitExec, withWorktree, worktreePreflight } from "../engine/worktree.ts";
17
+ import { type InProcessDeps, makeInProcessEngine } from "../engine/inproc.ts";
18
+ import { emptyUsage, type ProgressSnapshot } from "../engine/stream.ts";
19
+ import type { AgentRunSpec, StrategyEngine } from "../orchestration/sdk.ts";
20
+ import type { AgentResult } from "../orchestration/types.ts";
21
+ import type { InProcessBus } from "../bus/inproc.ts";
22
+
23
+ export interface EngineFactories {
24
+ makeEngine: typeof makeEngine;
25
+ makeInProcessEngine: typeof makeInProcessEngine;
26
+ }
27
+
28
+ export const DEFAULT_ENGINE_FACTORIES: EngineFactories = Object.freeze({ makeEngine, makeInProcessEngine });
29
+
30
+ export interface BuildEngineDeps {
31
+ agents: AgentConfig[];
32
+ contractDefs: Record<string, ContractDef>;
33
+ controller: PersonaController;
34
+ host: PersonaHost;
35
+ config: PiPersonaConfig;
36
+ personaConfigs: PersonaConfigStore;
37
+ lastCtx: ExtensionContext | undefined;
38
+ workerSpineText: string;
39
+ engineFactories: EngineFactories;
40
+ makeBrokerDeps: (ctx: ExtensionContext) => EngineAdapterBroker | undefined;
41
+ userAgentDir: () => string;
42
+ childPiSettingsEnv: () => Record<string, string>;
43
+ runLimits: RunLimits;
44
+ bus: InProcessBus;
45
+ supervisorHandle: string;
46
+ /** Breadcrumb for a provider reroute (toast + log); absent ⇒ silent, as before. */
47
+ onFallback?: (info: { from: string; to: string; agent: string }) => void;
48
+ /** Injected git runner for the non-worktree files-changed report (tests only); defaults to
49
+ * `defaultGitExec`. */
50
+ gitExec?: GitExec;
51
+ }
52
+
53
+ export type BuildEngine = (signal?: AbortSignal, onProgress?: (s: ProgressSnapshot) => void, engOpts?: { async?: boolean }) => StrategyEngine;
54
+
55
+ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
56
+ return (signal, onProgress, engOpts) => {
57
+ const {
58
+ agents, contractDefs, controller, host, config, personaConfigs, lastCtx, workerSpineText,
59
+ engineFactories, makeBrokerDeps, userAgentDir, childPiSettingsEnv, runLimits: RUN_LIMITS,
60
+ bus, supervisorHandle: SUPERVISOR, onFallback,
61
+ } = d();
62
+ const resolveAgent = (n: string): AgentConfig | undefined => agents.find((a) => a.name === n);
63
+ // A named contract file (contracts/<name>.contract.json) wins; "default" is the built-in.
64
+ const contracts = (n: string): ContractDef | undefined => contractDefs[n] ?? (n === "default" ? DEFAULT_CONTRACT : undefined);
65
+ const contractNames = (): string[] => installedContractNames(contractDefs);
66
+ const modelFor = (agent: string): string | undefined => {
67
+ const persona = controller.activePersona?.name;
68
+ return persona ? personaModels(personaConfigs, persona)[agent] : undefined;
69
+ };
70
+ // The main model thinks adaptively (it picks effort by difficulty); a spawned child
71
+ // can't inherit "adaptive" if its model doesn't support it, so give children an
72
+ // explicit level — the supervisor's (if concrete) or a sane default, overridable.
73
+ const supLevel = host.getThinkingLevel();
74
+ const childThinking = config.childThinking ?? (isThinkingLevel(supLevel) ? supLevel : "high");
75
+
76
+ // A persona that opted out of the layer (`spine: false`) opts its legs out too — otherwise
77
+ // a short verdict persona (judge/verify/audit) would still pay for the baseline on every
78
+ // sub-agent it spawns. The per-AGENT opt-out applies on top, inside each engine.
79
+ const legSpine = controller.activePersona?.spine === false ? "" : workerSpineText;
80
+
81
+ // Cross-process broker (spec B1-B7): lazily built on the FIRST actual child-engine
82
+ // construction below (worktree leg, MCP leg, or `PI_PERSONA_ENGINE=child`) — NOT on every
83
+ // `buildEngine` call, most of which build the (default) in-process engine and never
84
+ // touch a child at all; starting a host for those would be neither lazy nor needed.
85
+ // Memoized so both call sites below share ONE broker object (and its `peerGroup`
86
+ // registration) per `buildEngine` invocation. `config.broker` off or no live
87
+ // `ctx` yet ⇒ stays undefined forever, so `deps.broker` is never set.
88
+ let brokerDepsMemo: EngineAdapterBroker | undefined;
89
+ let brokerDepsBuilt = false;
90
+ const getBrokerDeps = (): EngineAdapterBroker | undefined => {
91
+ if (!brokerDepsBuilt) {
92
+ brokerDepsBuilt = true;
93
+ const sessionId = (lastCtx as { sessionManager?: { getSessionId?: () => string } } | undefined)?.sessionManager?.getSessionId?.();
94
+ if (config.broker && lastCtx && sessionId) brokerDepsMemo = makeBrokerDeps(lastCtx);
95
+ }
96
+ return brokerDepsMemo;
97
+ };
98
+
99
+ // A child-process engine pinned to a specific cwd — the seam worktree isolation runs
100
+ // through (a worktree needs its own working dir, i.e. a separate process).
101
+ const childEngineAt = (cwd: string): StrategyEngine => {
102
+ const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking, cwd };
103
+ deps.listAgents = () => agents.map((a) => a.name);
104
+ deps.listContracts = contractNames;
105
+ if (legSpine) deps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
106
+ if (signal) deps.signal = signal;
107
+ deps.childOptions = {
108
+ timeoutMs: RUN_LIMITS.timeoutMs,
109
+ hardTimeoutMs: config.agentHardTimeoutMs,
110
+ startupTimeoutMs: config.agentStartupTimeoutMs,
111
+ env: childPiSettingsEnv(),
112
+ };
113
+ // Feed progress here too (mirrors the plain-child branch): without it a worktree/mcp async leg
114
+ // never advances its tracker snapshot, so lastAdvanceAt freezes at launch and the leg is falsely
115
+ // flagged stalled while a genuine later wedge goes undetected.
116
+ if (onProgress) deps.childOptions.onProgress = onProgress;
117
+ const brokerDeps = getBrokerDeps();
118
+ if (brokerDeps) deps.broker = brokerDeps;
119
+ // Peer messaging obeys the persona's bus capability, and blocking asks are honoured
120
+ // only for async runs — same guards as the inproc engine below (spec B7 / §4.9).
121
+ const caps = controller.capabilities;
122
+ if (caps) deps.canUseBus = caps.canUseBus;
123
+ if (engOpts?.async) deps.allowBlocking = true;
124
+ return engineFactories.makeEngine(deps);
125
+ };
126
+
127
+ // v0.4: run sub-agents in-process (createAgentSession) instead of spawning `pi -p`.
128
+ let base: StrategyEngine;
129
+ if (config.engine === "inproc" && lastCtx) {
130
+ if (process.env.PI_PERSONA_DEBUG) process.stderr.write("[pi-persona] engine=inproc\n");
131
+ const ideps: InProcessDeps = { resolveAgent, contracts, modelFor, childThinking, modelRegistry: lastCtx.modelRegistry, cwd: lastCtx.cwd, agentDir: userAgentDir() };
132
+ ideps.listAgents = () => agents.map((a) => a.name);
133
+ ideps.listContracts = contractNames;
134
+ if (legSpine) ideps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
135
+ ideps.timeoutMs = RUN_LIMITS.timeoutMs; // idle watchdog — a hung session must settle, like the child engine's idle kill
136
+ ideps.hardTimeoutMs = config.agentHardTimeoutMs; // hard lifetime ceiling — catches a busy loop the idle watchdog never would
137
+ ideps.startupTimeoutMs = config.agentStartupTimeoutMs; // first-progress deadline — fast-fail a child that never started
138
+ if (signal) ideps.signal = signal;
139
+ if (onProgress) ideps.onProgress = onProgress;
140
+ if (lastCtx.model) ideps.defaultModel = `${lastCtx.model.provider}/${lastCtx.model.id}`;
141
+ // Comm plane: a `coaching: on` persona gives its children `contact_supervisor`.
142
+ // Blocking asks are honoured only for async runs (a sync run holds the turn → it
143
+ // can't answer, so blocking there would deadlock; the tool downgrades to one-way).
144
+ ideps.bus = bus;
145
+ ideps.supervisorHandle = SUPERVISOR;
146
+ if (controller.activePersona?.coaching) ideps.coaching = true;
147
+ // Peer messaging obeys the persona's bus capability (canUseBus; Task: sibling peer comm).
148
+ const caps = controller.capabilities;
149
+ if (caps) ideps.canUseBus = caps.canUseBus;
150
+ if (engOpts?.async) ideps.allowBlocking = true;
151
+ base = engineFactories.makeInProcessEngine(ideps);
152
+ } else {
153
+ if (process.env.PI_PERSONA_DEBUG) process.stderr.write("[pi-persona] engine=child\n");
154
+ const deps: EngineAdapterDeps = { resolveAgent, contracts, modelFor, childThinking };
155
+ deps.listAgents = () => agents.map((a) => a.name);
156
+ deps.listContracts = contractNames;
157
+ if (legSpine) deps.spine = legSpine; // legs get the worker variant (docs/SPINE.md)
158
+ if (signal) deps.signal = signal;
159
+ if (lastCtx?.cwd) deps.cwd = lastCtx.cwd;
160
+ deps.childOptions = {
161
+ timeoutMs: RUN_LIMITS.timeoutMs,
162
+ hardTimeoutMs: config.agentHardTimeoutMs,
163
+ startupTimeoutMs: config.agentStartupTimeoutMs,
164
+ env: childPiSettingsEnv(),
165
+ }; // idle watchdog + hard cap + startup deadline on every child; same Pi settings dir as inproc
166
+ if (onProgress) deps.childOptions.onProgress = onProgress;
167
+ const brokerDeps = getBrokerDeps();
168
+ if (brokerDeps) deps.broker = brokerDeps;
169
+ // Peer messaging obeys the persona's bus capability, and blocking asks are honoured
170
+ // only for async runs — same guards as the inproc engine above (spec B7 / §4.9).
171
+ const caps = controller.capabilities;
172
+ if (caps) deps.canUseBus = caps.canUseBus;
173
+ if (engOpts?.async) deps.allowBlocking = true;
174
+ base = engineFactories.makeEngine(deps);
175
+ }
176
+
177
+ // Worktree isolation: an agent/leg marked `isolation: worktree` runs in a throwaway git
178
+ // worktree via the child engine (its edits never touch the main tree), regardless of the
179
+ // default backend. This branch is deliberately fail-closed: a missing/dirty repository or
180
+ // failed worktree creation is a contract failure, never permission to run in the real cwd.
181
+ // Provider fallback (outermost): a run whose model PROVIDER fails at call time (auth,
182
+ // outage, 5xx, model-not-supported) is retried on the same model id under another
183
+ // authenticated provider — "priority to the supervisor's provider, but try others and
184
+ // switch on error". No ctx (no registry) ⇒ pass through. Each attempt still runs through
185
+ // worktree isolation + steering below.
186
+ const wrapFallback = (eng: StrategyEngine): StrategyEngine => {
187
+ if (!lastCtx) return eng;
188
+ const prefer = lastCtx.model?.provider;
189
+ return withModelFallback(eng, {
190
+ models: configuredModels(lastCtx),
191
+ ...(prefer ? { preferProvider: prefer } : {}),
192
+ ...(onFallback ? { onFallback } : {}),
193
+ });
194
+ };
195
+ const root = lastCtx?.cwd;
196
+ const gitExec = d().gitExec ?? defaultGitExec;
197
+ // Files-changed report for a leg that does NOT run in its own worktree: it shares the
198
+ // real checkout, so the only honest signal is "what did `git status` see change while
199
+ // this leg ran" — a before/after snapshot, not a private diff (see change-report.ts).
200
+ // Skipped for the worktree path below, which already returns a real diff artifact, and
201
+ // for the whole feature when `PI_PERSONA_LEG_CHANGE_REPORT` is off.
202
+ //
203
+ // The repository root is resolved ONCE here (a pure filesystem walk, no process spawn)
204
+ // and threaded into BOTH captures as their `knownGitRoot` — each then skips its own
205
+ // walk AND its `rev-parse` liveness gate, so a leg pays for exactly two `git status`
206
+ // spawns (one at launch, one at settle) instead of four.
207
+ const withChangeReport = async (run: () => Promise<AgentResult>, spec: AgentRunSpec): Promise<AgentResult> => {
208
+ if (!root || config.changeReport === false) return run();
209
+ const gitRoot = findGitRoot(root);
210
+ if (!gitRoot) return run();
211
+ const before = await captureStatus(gitRoot, gitExec, gitRoot);
212
+ const result = await run();
213
+ if (!before) return result;
214
+ const after = await captureStatus(gitRoot, gitExec, gitRoot);
215
+ if (!after) return result;
216
+ const block = renderChangeReport(diffStatus(before, after), spec.writeSet);
217
+ return block ? appendBlock(result, block) : result;
218
+ };
219
+ return wrapFallback({
220
+ async run(spec, perProgress, perSignal, perSteer) {
221
+ const iso = spec.isolation ?? resolveAgent(spec.agent)?.isolation;
222
+ if (iso === "worktree") {
223
+ if (!root) {
224
+ return isolatedWorktreeFailure(spec, "worktree isolation requires a real Git cwd; remove isolation: \"worktree\" or run inside a Git checkout");
225
+ }
226
+ const preflight = await worktreePreflight(root, defaultGitExec);
227
+ if (!preflight.ok) return isolatedWorktreeFailure(spec, preflight.error);
228
+ try {
229
+ return await withWorktree(root, defaultGitExec, async (dir) => {
230
+ const result = await childEngineAt(dir).run({ ...spec, isolation: "none" }, perProgress, perSignal, perSteer);
231
+ const artifact = await captureWorktreeArtifact(dir, defaultGitExec);
232
+ const hasOutputArtifact = hasUnifiedDiff(result.output);
233
+ if (!result.ok) {
234
+ return artifact.ok && artifact.diff.trim().length > 0
235
+ ? appendWorktreeArtifact(result, artifact.diff)
236
+ : result;
237
+ }
238
+ if (!artifact.ok) {
239
+ if (hasOutputArtifact) return result;
240
+ return isolatedWorktreeFailure(spec, `${artifact.error}; the isolated leg returned success without an exportable diff/artifact`);
241
+ }
242
+ if (artifact.diff.trim().length > 0) return appendWorktreeArtifact(result, artifact.diff);
243
+ if (hasOutputArtifact) return result;
244
+ return isolatedWorktreeFailure(
245
+ spec,
246
+ "isolated leg returned success but produced no unified diff or artifact; its worktree was discarded — return a complete diff or remove isolation: \"worktree\"",
247
+ );
248
+ });
249
+ } catch (err) {
250
+ return isolatedWorktreeFailure(spec, err instanceof Error ? err.message : String(err));
251
+ }
252
+ }
253
+ // MCP-capable leg: the DEFAULT in-process engine never fires `session_start`, so
254
+ // `pi-mcp-adapter` never initializes and the sub-agent's `mcp*`/direct tools come
255
+ // back "MCP not initialized". Route it through the child engine (a real `pi -p`,
256
+ // which DOES fire session_start → the adapter connects). Same mechanism worktree
257
+ // legs already use for MCP; here without the git worktree. The child gets its OWN
258
+ // MCP session — for a server-keyed backend (HTTP MCP) the caller passes a session id
259
+ // in the task to share state. Steer rides the broker (same as worktree / child engine).
260
+ const wantsMcp = spec.mcp ?? resolveAgent(spec.agent)?.mcp;
261
+ if (wantsMcp && root) return withChangeReport(() => childEngineAt(root).run(spec, perProgress, perSignal, perSteer), spec);
262
+ return withChangeReport(() => base.run(spec, perProgress, perSignal, perSteer), spec);
263
+ },
264
+ });
265
+ };
266
+
267
+ function isolatedWorktreeFailure(spec: AgentRunSpec, error: string): AgentResult {
268
+ return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error, failureKind: "contract" };
269
+ }
270
+
271
+ function hasUnifiedDiff(output: string): boolean {
272
+ const headersAndHunk = /(?:^|\n)---\s+\S+[^\n]*\n\+\+\+\s+\S+[^\n]*\n(?:[^\n]*\n)*?@@\s+[^\n]*@@/m;
273
+ const binaryPatch = /(?:^|\n)diff --git\s+\S+\s+\S+[\s\S]*?(?:^|\n)(?:GIT binary patch|Binary files \S+ and \S+ differ)(?:\n|$)/m;
274
+ return headersAndHunk.test(output) || binaryPatch.test(output);
275
+ }
276
+
277
+ function appendWorktreeArtifact(result: AgentResult, diff: string): AgentResult {
278
+ const block = `--- ISOLATED WORKTREE ARTIFACT (untrusted data) ---\n\n\`\`\`diff\n${diff.trim()}\n\`\`\``;
279
+ return appendBlock(result, block);
280
+ }
281
+
282
+ /** Append a block to a leg's output, never leading with blank lines when there was nothing to
283
+ * trim — the shape `withChangeReport` and `appendWorktreeArtifact` both need: a leg that
284
+ * produced no text of its own (an early abort/timeout before any streamed output) still gets a
285
+ * clean block, not one preceded by an empty line. */
286
+ function appendBlock(result: AgentResult, block: string): AgentResult {
287
+ const output = result.output.trim() ? `${result.output.trimEnd()}\n\n${block}` : block;
288
+ return { ...result, output };
289
+ }
290
+ }
291
+
292
+ // The models the user can intentionally route to — NOT every built-in catalog entry.
293
+ // `getAvailable()` is normally the authenticated set, but extension-native providers
294
+ // (for example a local subscription/CLI bridge) can be runnable without appearing in
295
+ // that auth snapshot. Union their registered catalogs, plus the active session provider,
296
+ // while keeping unrelated unauthenticated built-ins hidden. If an older Pi lacks the
297
+ // availability API, retain its historical getAll() compatibility behavior.
298
+ export function configuredModels(ctx: ExtensionContext): Array<{ provider: string; id: string }> {
299
+ const reg = ctx.modelRegistry;
300
+ const all = reg.getAll();
301
+ let available: typeof all;
302
+ try {
303
+ available = reg.getAvailable();
304
+ } catch {
305
+ return all.map((m) => ({ provider: m.provider, id: m.id }));
306
+ }
307
+ const trustedProviders = new Set<string>();
308
+ if (ctx.model?.provider) trustedProviders.add(ctx.model.provider);
309
+ try {
310
+ const registered = (reg as typeof reg & { getRegisteredProviderIds?: () => string[] }).getRegisteredProviderIds?.() ?? [];
311
+ for (const provider of registered) trustedProviders.add(provider);
312
+ } catch {
313
+ /* a third-party/older registry facade may not expose extension provider ids */
314
+ }
315
+ const byRef = new Map(available.map((m) => [`${m.provider}\0${m.id}`, m]));
316
+ for (const model of all) {
317
+ if (trustedProviders.has(model.provider)) byRef.set(`${model.provider}\0${model.id}`, model);
318
+ }
319
+ const list = [...byRef.values()];
320
+ return list.map((m) => ({ provider: m.provider, id: m.id }));
321
+ }