@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,857 @@
1
+ /**
2
+ * InProcessEngine (v0.4) — a second `StrategyEngine` backend that runs a sub-agent
3
+ * IN THIS PROCESS via Pi's `createAgentSession`, instead of spawning `pi --mode
4
+ * json -p`. Same seam (`run` → `AgentResult`), same stream parser (`stream.ts`
5
+ * folds the session's `AgentEvent`s), same contract validation. This is what
6
+ * unlocks live **steering** (`agent.steer`) and drops the per-child spawn.
7
+ *
8
+ * Fork-bomb guard: the sub-session must NOT re-activate pi-persona (which would give
9
+ * the child the `delegate`/`council` tools + turn interception → recursion). We set
10
+ * `PI_PERSONA_DISABLE` for the duration of session creation (the same flag the child
11
+ * path passes via env) and exclude the orchestration tools defensively.
12
+ *
13
+ * The session factory is injectable (`deps.createSession`) — exactly like the child
14
+ * engine's `resolveInvocation` — so the whole path is deterministically testable with
15
+ * a fake session, without a live model. The default builds a real `createAgentSession`.
16
+ *
17
+ * This is the DEFAULT backend; `PI_PERSONA_ENGINE=child` opts back to the spawn-based
18
+ * `ChildProcessEngine`. It drops in behind the seam without touching strategies/UI.
19
+ */
20
+
21
+ import { createAgentSession, DefaultResourceLoader, type ModelRegistry, SessionManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
22
+
23
+ import type { AgentConfig } from "../agents/agent.ts";
24
+ import type { InProcessBus } from "../bus/inproc.ts";
25
+ import { makeContactSupervisorTool } from "../bus/contact.ts";
26
+ import { makeContactPeerTool } from "../bus/peers.ts";
27
+ import { type ContractDef, contractInstructions, parseAndValidate, pinContract, type PinnedContract } from "../core/contract.ts";
28
+ import { sanitizeDisplayLabel } from "../core/display-label.ts";
29
+ import { assignedIdentityPrompt } from "../core/naming.ts";
30
+ import { attributeInbound } from "../core/fence.ts";
31
+ import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
32
+ import { roleHint } from "../orchestration/roster.ts";
33
+ import { type AgentRunSpec, isPositiveFiniteMs, type StrategyEngine } from "../orchestration/sdk.ts";
34
+ import type { AgentResult } from "../orchestration/types.ts";
35
+ import { looksLikeProviderError } from "./errors.ts";
36
+ import { nextChildHandle } from "./handles.ts";
37
+ import { combineSignals } from "./signals.ts";
38
+ import { unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
39
+ import { applyEvent, createStreamState, emptyUsage, type ProgressSnapshot, snapshot } from "./stream.ts";
40
+
41
+ /** The minimal slice of Pi's AgentSession the engine drives — also the test seam. */
42
+ export interface InProcSession {
43
+ subscribe(listener: (event: unknown) => void): () => void;
44
+ prompt(input: string): Promise<void>;
45
+ agent: { abort(): void; waitForIdle(): Promise<void>; steer(message: unknown): void };
46
+ dispose(): void;
47
+ }
48
+
49
+ export interface CreateSessionOptions {
50
+ model: unknown;
51
+ modelRegistry: ModelRegistry;
52
+ /** Cooperative cancellation for resource/session initialization. Pi's loader does not
53
+ * currently accept a signal itself, but injected factories can stop promptly and the
54
+ * default factory checks it before/after each uncancellable Pi operation. */
55
+ signal?: AbortSignal;
56
+ /** The host's canonical runtime (Pi >=0.81); shared so auth/provider registrations survive. */
57
+ modelRuntime?: unknown;
58
+ cwd: string;
59
+ /** Pi's global agent dir (~/.pi/agent) for the sub-agent's resource loader. */
60
+ agentDir: string;
61
+ tools?: string[];
62
+ excludeTools?: string[];
63
+ thinkingLevel?: ThinkingLevel;
64
+ /** The agent's persona (its .md) — appended to the base system prompt. */
65
+ systemPrompt?: string;
66
+ /** Extra tools injected into the child session (e.g. `contact_supervisor`). */
67
+ customTools?: ToolDefinition[];
68
+ }
69
+
70
+ export type CreateInProcSession = (opts: CreateSessionOptions) => Promise<InProcSession>;
71
+
72
+ /**
73
+ * Pi 0.81 moved the canonical auth/provider state from the extension-facing
74
+ * ModelRegistry to a ModelRuntime passed directly to createAgentSession. The
75
+ * compatibility facade still owns that runtime, but intentionally keeps it
76
+ * private in its public TypeScript API. Read the runtime only when the expected
77
+ * methods are present; old Pi versions and test facades then retain the legacy
78
+ * modelRegistry path.
79
+ */
80
+ function sharedModelRuntime(registry: ModelRegistry): unknown {
81
+ const candidate = (registry as unknown as { runtime?: unknown }).runtime;
82
+ if (!candidate || typeof candidate !== "object") return undefined;
83
+ const runtime = candidate as { getAuth?: unknown; getModel?: unknown; stream?: unknown };
84
+ return typeof runtime.getAuth === "function" && typeof runtime.getModel === "function" && typeof runtime.stream === "function"
85
+ ? candidate
86
+ : undefined;
87
+ }
88
+
89
+ export interface InProcessDeps {
90
+ resolveAgent: (name: string) => AgentConfig | undefined;
91
+ contracts?: (name: string) => ContractDef | undefined;
92
+ /** The host session's registry — shared so the sub-agent reuses the loader's auth. */
93
+ modelRegistry: ModelRegistry;
94
+ cwd: string;
95
+ /** Pi's global agent dir (~/.pi/agent); defaults to cwd if omitted (tests). */
96
+ agentDir?: string;
97
+ /** Per-agent model override (e.g. a persona's configured ensemble models). */
98
+ modelFor?: (agent: string) => string | undefined;
99
+ /** The session/loader model — the last-resort fallback when an agent declares none
100
+ * (the child engine gets this for free from `pi -p`'s default; inproc must pass it). */
101
+ defaultModel?: string;
102
+ /** Explicit thinking level for the sub-agent (the supervisor's, or a default). */
103
+ childThinking?: string;
104
+ /** The shared behavioral layer (docs/SPINE.md), prepended to every leg's prompt. This is the
105
+ * WORKER variant (`prompts/spine.worker.md`), not the supervisor text — a leg runs headless
106
+ * with no user to confirm anything with. Absent/empty (the default) ⇒ the composed prompt is
107
+ * byte-identical to the pre-spine one. */
108
+ spine?: string;
109
+ /** Whole-run abort signal (combined with each call's own signal). */
110
+ signal?: AbortSignal;
111
+ /** Construction-time progress sink (used when `run` is called without its own). */
112
+ onProgress?: (snap: ProgressSnapshot) => void;
113
+ /** Known agent names, for self-correcting unknown-agent errors (mirrors the model path). */
114
+ listAgents?: () => string[];
115
+ /** Installed output-contract names (built-in `default` + files) for the self-correcting hint. */
116
+ listContracts?: () => string[];
117
+ /** Session factory — defaults to a real `createAgentSession`; injected in tests. */
118
+ createSession?: CreateInProcSession;
119
+ /** The semantic comm plane: when present + `coaching`, each child gets a
120
+ * `contact_supervisor` tool bound to this bus so it can reach the supervisor mid-run. */
121
+ bus?: InProcessBus;
122
+ /** The supervisor's bus handle (default `"supervisor"`). */
123
+ supervisorHandle?: string;
124
+ /** Inject `contact_supervisor` into children (persona `coaching: on`). Default off. */
125
+ coaching?: boolean;
126
+ /** Allow a child's `decision`/`interview` to BLOCK for a reply — async runs only.
127
+ * Sync runs hold the supervisor's turn, so blocking would deadlock: keep this false. */
128
+ allowBlocking?: boolean;
129
+ /** Persona-level bus capability (`EffectiveCapabilities.canUseBus`). Default true.
130
+ * When false, a spec's `peers` request is ignored (no `contact_peer` tool is bound). */
131
+ canUseBus?: boolean;
132
+ /** IDLE window (ms): a session that emits NO events for this long is treated as hung
133
+ * and aborted (mirrors the child engine's idle kill). 0/absent = no watchdog. Stays armed
134
+ * for every child, coaching or not: when it fires, the bus is asked whether THIS child has
135
+ * a live ask outstanding (`bus.hasPendingAskFrom`) — true re-arms the window instead of
136
+ * killing, false means the silence is real (R9). */
137
+ timeoutMs?: number;
138
+ /** HARD wall-clock cap (ms): a definite lifetime ceiling, armed ONCE and never reset by
139
+ * events — so it catches a busy-but-non-converging child (a loop that keeps emitting) that
140
+ * the idle watchdog, which any event re-arms, never would. 0/absent = no cap. Applies
141
+ * unconditionally, including to a coaching child legitimately blocked on a reply. */
142
+ hardTimeoutMs?: number;
143
+ /** STARTUP deadline (ms): a session that never makes PROGRESS (no completed turn, no tokens,
144
+ * no streamed output) within this window is aborted as a stalled start — the case the idle
145
+ * window is too generous for. The FIRST progress cancels it permanently, so a
146
+ * slow-but-streaming turn is never touched. 0/absent = no startup deadline. Stays armed for
147
+ * every child, same bus consult as the idle window above before it actually cancels (R9).
148
+ *
149
+ * CHOOSE THE VALUE FOR THE FIRST PROVIDER ROUND TRIP, not for init alone. Only the child's
150
+ * OWN assistant output counts as progress — pi emits message_start/message_end for the
151
+ * prompt it just delivered too, and stream.ts filters those out — so the window has to
152
+ * cover the whole way to the FIRST provider response. A queued or rate-limited provider, a
153
+ * cold serverless endpoint or a local model doing a long prompt eval can spend minutes
154
+ * there, and this deadline aborts such a leg outright (failureKind "timeout"); the idle
155
+ * watchdog would not, because any event at all re-arms it. */
156
+ startupTimeoutMs?: number;
157
+ }
158
+
159
+ // The sub-agent must never re-enter the supervisor's orchestration surface. This is the SECOND
160
+ // line of defense (the first is that a child's whole pi-persona activation short-circuits under
161
+ // PI_PERSONA_DISABLE=1 and registers no tools at all, on either engine — see I2 in
162
+ // docs/ARCHITECTURE.md); nesting depth is capped at 1 structurally, not by a numeric run limit.
163
+ export const ORCHESTRATION_TOOLS = ["delegate", "council", "orchestrate", "flow"];
164
+
165
+ /** Union a child session's excludeTools with `ORCHESTRATION_TOOLS`, so a delegated leg has no
166
+ * tool call that could start a grandchild, however it is asked to — regardless of what the
167
+ * agent's own config additionally excludes. */
168
+ export function withOrchestrationToolsExcluded(extra: readonly string[] | undefined): string[] {
169
+ return [...new Set([...ORCHESTRATION_TOOLS, ...(extra ?? [])])];
170
+ }
171
+
172
+ /** Preserve the semantic difference between an absent allowlist (Pi defaults) and an explicit
173
+ * empty one (no tools). Custom communication tools join only a non-empty allowlist; an empty
174
+ * grant must not quietly grow capabilities during session construction. */
175
+ export function mergeSessionToolAllowlist(tools: readonly string[] | undefined, customNames: readonly string[]): string[] | undefined {
176
+ if (tools === undefined) return undefined;
177
+ if (tools.length === 0) return [];
178
+ return [...new Set([...tools, ...customNames])];
179
+ }
180
+
181
+ /** Default factory: a real in-process Pi session. The resource loader appends the
182
+ * agent's persona to the base system prompt (the in-process `--append-system-prompt`).
183
+ * Extensions DO load (so auth providers like pi-claude work) — the fork-bomb guard is
184
+ * that pi-persona self-disables under `PI_PERSONA_DISABLE` (set around creation), with
185
+ * the orchestration tools excluded as a second line of defense. */
186
+ const createPiSession: CreateInProcSession = async (opts) => {
187
+ if (opts.signal?.aborted) throw new DOMException("Session initialization aborted", "AbortError");
188
+ const loader = new DefaultResourceLoader({
189
+ cwd: opts.cwd,
190
+ agentDir: opts.agentDir,
191
+ ...(opts.systemPrompt ? { appendSystemPrompt: [opts.systemPrompt] } : {}),
192
+ });
193
+ await loader.reload();
194
+ if (opts.signal?.aborted) throw new DOMException("Session initialization aborted", "AbortError");
195
+ // If a `tools` allowlist is set, the injected custom tools must be allowed too,
196
+ // otherwise the allowlist would filter them out of the child's active set.
197
+ const customNames = (opts.customTools ?? []).map((t) => t.name);
198
+ const tools = mergeSessionToolAllowlist(opts.tools, customNames);
199
+ // Forward-compat with the model-runtime migration (pi > 0.80.7). pi <= 0.80.7 reads
200
+ // `CreateAgentSessionOptions.modelRegistry` to share the host's auth/model registry with the
201
+ // sub-session; newer pi takes the canonical `modelRuntime` instead. The extension-facing
202
+ // ModelRegistry keeps that runtime private in its type surface, so `sharedModelRuntime()` above
203
+ // recovers it only when the runtime shape is present. Passing it is important even when auth is
204
+ // on disk: native providers and runtime credentials can exist only in the supervisor's memory.
205
+ // `agentDir` remains a valid fallback for older/standalone SDKs, and the legacy key stays outside
206
+ // the object literal so current SDK types do not reject it.
207
+ const sessionOptions: NonNullable<Parameters<typeof createAgentSession>[0]> = {
208
+ model: opts.model as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>,
209
+ sessionManager: SessionManager.inMemory(opts.cwd),
210
+ resourceLoader: loader,
211
+ cwd: opts.cwd,
212
+ agentDir: opts.agentDir,
213
+ ...(opts.modelRuntime !== undefined
214
+ ? { modelRuntime: opts.modelRuntime as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["modelRuntime"]> }
215
+ : {}),
216
+ excludeTools: withOrchestrationToolsExcluded(opts.excludeTools),
217
+ ...(tools !== undefined ? { tools } : {}),
218
+ ...(opts.customTools && opts.customTools.length > 0 ? { customTools: opts.customTools } : {}),
219
+ // `opts.thinkingLevel` is our local ThinkingLevel superset — cast to pi's field type at the
220
+ // boundary (same rationale as PersonaHost.setThinkingLevel: a `max`-style level only reaches
221
+ // here on a pi that supports it, and pi clamps an unknown level regardless).
222
+ ...(opts.thinkingLevel
223
+ ? { thinkingLevel: opts.thinkingLevel as NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]> }
224
+ : {}),
225
+ };
226
+ (sessionOptions as { modelRegistry?: unknown }).modelRegistry = opts.modelRegistry;
227
+ const { session } = await createAgentSession(sessionOptions);
228
+ if (opts.signal?.aborted) {
229
+ try {
230
+ session.dispose();
231
+ } catch {
232
+ // The initialization cancellation result still owns the lifecycle if disposal throws.
233
+ }
234
+ throw new DOMException("Session initialization aborted", "AbortError");
235
+ }
236
+ return {
237
+ subscribe: (l) => session.subscribe(l as Parameters<typeof session.subscribe>[0]),
238
+ prompt: async (input) => {
239
+ await session.prompt(input);
240
+ },
241
+ agent: {
242
+ abort: () => session.agent.abort(),
243
+ waitForIdle: () => session.agent.waitForIdle(),
244
+ steer: (message) => session.agent.steer(message as Parameters<typeof session.agent.steer>[0]),
245
+ },
246
+ dispose: () => session.dispose(),
247
+ };
248
+ };
249
+
250
+ /** Resolve a model ref (`provider/id`, or a bare id) to a registry Model. */
251
+ function resolveModel(reg: ModelRegistry, ref: string | undefined): unknown {
252
+ if (!ref) return undefined;
253
+ const slash = ref.indexOf("/");
254
+ if (slash > 0) {
255
+ const m = reg.find(ref.slice(0, slash), ref.slice(slash + 1));
256
+ if (m) return m;
257
+ }
258
+ return reg.getAll().find((m) => m.id === ref);
259
+ }
260
+
261
+ // Ref-counted fork-bomb guard: PI_PERSONA_DISABLE stays "1" while ANY sub-session is being
262
+ // built (a parallel strategy builds several at once), restored only when the LAST one finishes.
263
+ // A per-call save/restore around the async `createSession` races: it can clear the guard
264
+ // mid-build (fork-bomb window) or leave it stuck. Increment/decrement never cross the await.
265
+ //
266
+ // PI_PERSONA_LEG rides alongside it as a DEDICATED "this session is a delegated worker leg" marker.
267
+ // PI_PERSONA_DISABLE alone is ambiguous — a user can set it as a kill switch — so a companion
268
+ // extension (e.g. pi-persona-mind) cannot tell a real leg from a disabled supervisor by DISABLE
269
+ // alone. LEG is set ONLY here (and in the child engine), never by a user, so it names a leg exactly.
270
+ let disableDepth = 0;
271
+ let savedDisable: string | undefined;
272
+ let savedLeg: string | undefined;
273
+ function pushDisableGuard(): void {
274
+ if (disableDepth === 0) {
275
+ savedDisable = process.env.PI_PERSONA_DISABLE;
276
+ savedLeg = process.env.PI_PERSONA_LEG;
277
+ }
278
+ disableDepth += 1;
279
+ process.env.PI_PERSONA_DISABLE = "1";
280
+ process.env.PI_PERSONA_LEG = "1";
281
+ }
282
+ function popDisableGuard(): void {
283
+ disableDepth -= 1;
284
+ if (disableDepth === 0) {
285
+ if (savedDisable === undefined) delete process.env.PI_PERSONA_DISABLE;
286
+ else process.env.PI_PERSONA_DISABLE = savedDisable;
287
+ if (savedLeg === undefined) delete process.env.PI_PERSONA_LEG;
288
+ else process.env.PI_PERSONA_LEG = savedLeg;
289
+ }
290
+ }
291
+
292
+ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
293
+ const createSession = deps.createSession ?? createPiSession;
294
+ const supervisorHandle = deps.supervisorHandle ?? "supervisor";
295
+ // Per-run contract pinning (I3), mirroring the child adapter.
296
+ const pinned = new Map<string, PinnedContract>();
297
+ const pinnedDef = (name: string): ContractDef | undefined => {
298
+ const cached = pinned.get(name);
299
+ if (cached) return cached.def;
300
+ const def = deps.contracts?.(name);
301
+ if (!def) return undefined;
302
+ const fresh = pinContract(def);
303
+ pinned.set(name, fresh);
304
+ return fresh.def;
305
+ };
306
+
307
+ // Peer registry — PER ENGINE INSTANCE. buildEngine makes a fresh engine per
308
+ // delegate/council/flow-phase/async-launch, so this map naturally scopes `contact_peer`
309
+ // to the members of ONE run: concurrent runs on the same bus never see each other.
310
+ const peerLabels = new Map<string, string>();
311
+
312
+ return {
313
+ async run(spec: AgentRunSpec, onProgress?, callSignal?, onSteerable?): Promise<AgentResult> {
314
+ const cfg = deps.resolveAgent(spec.agent);
315
+ if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
316
+ // A requested contract is a runtime guard, not merely prompt decoration. Resolve it
317
+ // before model/session construction so a missing name can never run unconstrained.
318
+ const requestedContract = spec.outputContract?.trim();
319
+ const requestedDef = requestedContract ? pinnedDef(requestedContract) : undefined;
320
+ if (requestedContract && !requestedDef) return unknownContractFailure(spec.agent, requestedContract, deps.listContracts?.() ?? []);
321
+
322
+ const ref = spec.model ?? deps.modelFor?.(spec.agent) ?? cfg.model ?? deps.defaultModel;
323
+ const model = resolveModel(deps.modelRegistry, ref);
324
+ if (!model) {
325
+ const src = spec.model ? "spec" : deps.modelFor?.(spec.agent) ? "agent picker" : cfg.model ? "agent config" : "default";
326
+ // Make the failure self-correcting: name a few real registry refs (nearest
327
+ // matches first) so the supervisor's retry can pick a valid model at once.
328
+ const all = deps.modelRegistry.getAll().map((m) => `${m.provider}/${m.id}`);
329
+ const frag = (ref ?? "").split("/").pop()?.split(":")[0]?.toLowerCase() ?? "";
330
+ const near = frag ? all.filter((r) => r.toLowerCase().includes(frag)) : [];
331
+ const hint = (near.length > 0 ? near : all).slice(0, 5);
332
+ return {
333
+ agent: spec.agent,
334
+ output: "",
335
+ usage: emptyUsage(),
336
+ ok: false,
337
+ error: `[${spec.agent} · ${ref ?? "(no model)"}] model not found in registry (from ${src})${hint.length > 0 ? ` — try e.g.: ${hint.join(", ")}` : ""}`,
338
+ failureKind: "unknown-model",
339
+ };
340
+ }
341
+ // The canonical provider/id this run resolved to — surfaced on every result (for the
342
+ // UI/cost) and, on a provider failure, used as the seed of the fallback chain.
343
+ const rm = model as { provider?: string; id?: string };
344
+ const resolvedRef = rm.provider && rm.id ? `${rm.provider}/${rm.id}` : ref;
345
+ const thinkingLevel = isThinkingLevel(deps.childThinking) ? deps.childThinking : undefined;
346
+ const tools = spec.tools ?? cfg.tools;
347
+ const excludeTools = cfg.excludeTools;
348
+
349
+ // Diagnostic tag: which agent + which model ref + whether it was dynamically
350
+ // specialised (skills / model / tools override on the spec). Users need this
351
+ // to tell "the delegate typo'd the model" from "the provider rejected it".
352
+ const overrides: string[] = [];
353
+ if (spec.model) overrides.push("model");
354
+ if (spec.skills && spec.skills.length > 0) overrides.push("skills");
355
+ if (spec.tools !== undefined) overrides.push("tools");
356
+ const dyn = overrides.length > 0 ? ` +dyn(${overrides.join(",")})` : "";
357
+ const tag = `[${spec.agent} · ${ref ?? "(no model)"}${dyn}]`;
358
+
359
+ const state = createStreamState();
360
+ const signal = combineSignals(deps.signal, callSignal);
361
+
362
+ // Already cancelled (e.g. a queued async run stopped before its slot came up):
363
+ // don't build a session or burn a model call — settle as aborted right away.
364
+ if (signal?.aborted) {
365
+ return { agent: spec.agent, output: "", usage: emptyUsage(), ok: false, error: `${tag} agent aborted`, ...(resolvedRef ? { modelUsed: resolvedRef } : {}), failureKind: "abort" };
366
+ }
367
+
368
+ const modelRuntime = sharedModelRuntime(deps.modelRegistry);
369
+ const sessionOpts: CreateSessionOptions = {
370
+ model,
371
+ modelRegistry: deps.modelRegistry,
372
+ ...(modelRuntime !== undefined ? { modelRuntime } : {}),
373
+ cwd: deps.cwd,
374
+ agentDir: deps.agentDir ?? deps.cwd,
375
+ };
376
+ if (tools !== undefined) sessionOpts.tools = tools;
377
+ if (excludeTools && excludeTools.length > 0) sessionOpts.excludeTools = excludeTools;
378
+ if (thinkingLevel) sessionOpts.thinkingLevel = thinkingLevel;
379
+ // The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
380
+ // on-the-fly `role` specialisation from the spec. All three reach the session as the
381
+ // loader's `appendSystemPrompt`, so a leg keeps Pi's full base prompt either way — the
382
+ // layer adds behavioral consistency, it does not fill a scaffolding gap.
383
+ const layer = cfg.spine === false ? undefined : deps.spine?.trim();
384
+ const personaPrompt = [layer, cfg.systemPrompt?.trim(), assignedIdentityPrompt(spec.name), spec.role?.trim()].filter(Boolean).join("\n\n");
385
+ if (personaPrompt) sessionOpts.systemPrompt = personaPrompt;
386
+
387
+ // Comm plane: give this child a `contact_supervisor` tool bound to a unique handle
388
+ // (persona coaching, §4.9), and/or a `contact_peer` tool when the STRATEGY opted the
389
+ // run into sibling messaging (`spec.peers`, gated by the persona's `canUseBus`).
390
+ let childHandle: string | undefined;
391
+ const wantsPeers = spec.peers === true && (deps.canUseBus ?? true) && deps.bus !== undefined;
392
+ if (spec.peers === true && !wantsPeers && process.env.PI_PERSONA_DEBUG) {
393
+ process.stderr.write(`[pi-persona] peers requested for ${spec.agent} but bus/capability unavailable — running without contact_peer\n`);
394
+ }
395
+ if (deps.bus && (deps.coaching || wantsPeers)) {
396
+ childHandle = nextChildHandle(spec.agent);
397
+ deps.bus.register(supervisorHandle);
398
+ deps.bus.register(childHandle);
399
+ const customTools: ToolDefinition[] = [];
400
+ if (deps.coaching) {
401
+ customTools.push(
402
+ makeContactSupervisorTool(deps.bus, childHandle, supervisorHandle, { allowBlocking: deps.allowBlocking ?? false }),
403
+ );
404
+ }
405
+ if (wantsPeers) {
406
+ const label = spec.name?.trim()
407
+ ? sanitizeDisplayLabel(spec.name, childHandle)
408
+ : spec.role ? `${childHandle} (${roleHint(spec.role)})` : childHandle;
409
+ peerLabels.set(childHandle, label);
410
+ const self = childHandle;
411
+ customTools.push(
412
+ makeContactPeerTool(deps.bus, self, {
413
+ listPeers: () => [...peerLabels.entries()].filter(([h]) => h !== self).map(([h, l]) => ({ handle: h, label: l })),
414
+ }),
415
+ );
416
+ }
417
+ sessionOpts.customTools = customTools;
418
+ }
419
+
420
+ // The handle is live on the SESSION-LONG bus from here on, while the only unregister sits
421
+ // in the run's own finally below. Anything that throws before we reach it must release
422
+ // it, or a phantom peer haunts `intercom list` (collecting dead letters nothing drains)
423
+ // for the rest of the supervisor's session.
424
+ let handleReleased = false;
425
+ const releaseHandle = (): void => {
426
+ if (!childHandle || handleReleased) return;
427
+ handleReleased = true;
428
+ peerLabels.delete(childHandle);
429
+ deps.bus?.unregister(childHandle);
430
+ };
431
+
432
+ // `agent.abort()` is a cancellation REQUEST, not a settlement guarantee: a provider/tool
433
+ // can leave both `prompt()` and `waitForIdle()` pending after accepting it. Keep a separate
434
+ // gate that every abort cause trips, so the engine always reaches its finally-cleanup instead
435
+ // of retaining the session (and its async semaphore slot) forever. The same gate also settles
436
+ // an initialization that cannot be interrupted by Pi's current resource-loader API.
437
+ let releaseAbortGate: () => void = () => {};
438
+ const abortGate = new Promise<void>((resolve) => {
439
+ releaseAbortGate = resolve;
440
+ });
441
+ let session!: InProcSession;
442
+ let initializedSession: InProcSession | undefined;
443
+ let aborted = false;
444
+ let timedOut = false;
445
+ let hardTimedOut = false;
446
+ let startupTimedOut = false;
447
+ let cancellationRequested = false;
448
+ const initController = new AbortController();
449
+ const abortSession = (): void => {
450
+ const target = initializedSession;
451
+ try {
452
+ target?.agent.abort();
453
+ } catch {
454
+ // Settlement and dispose must still run when the host abort hook itself throws.
455
+ } finally {
456
+ releaseAbortGate();
457
+ }
458
+ };
459
+ const requestCancellation = (kind: "abort" | "idle" | "startup" | "hard"): void => {
460
+ // Host abort hooks can synchronously emit more events or cancellation signals.
461
+ // Preserve the first cause and invoke the host abort hook only once.
462
+ if (cancellationRequested) return;
463
+ cancellationRequested = true;
464
+ if (kind === "abort") aborted = true;
465
+ else if (kind === "idle") timedOut = true;
466
+ else if (kind === "hard") hardTimedOut = true;
467
+ else startupTimedOut = true;
468
+ if (!initController.signal.aborted) initController.abort();
469
+ abortSession();
470
+ };
471
+
472
+ // Idle watchdog (mirrors the child engine's idle kill): a session that emits no
473
+ // events for `timeoutMs` is hung (e.g. a stuck provider stream) — without this the
474
+ // DEFAULT engine would await `waitForIdle()` forever and the run would never settle.
475
+ // Any event re-arms the clock, so a long-but-active agent is never killed. Stays armed
476
+ // for every child, coaching or not: when it fires, the bus is consulted first (R9) —
477
+ // a live pending ask from THIS child re-arms the window instead of killing.
478
+ const specTimeoutMs = isPositiveFiniteMs(spec.timeoutMs) ? spec.timeoutMs : undefined;
479
+ const watchdogMs = specTimeoutMs ?? (isPositiveFiniteMs(deps.timeoutMs) ? deps.timeoutMs : 0);
480
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
481
+ const disarmIdle = (): void => {
482
+ if (idleTimer) {
483
+ clearTimeout(idleTimer);
484
+ idleTimer = undefined;
485
+ }
486
+ };
487
+ const armIdle = (): void => {
488
+ disarmIdle();
489
+ if (watchdogMs <= 0 || cancellationRequested) return;
490
+ idleTimer = setTimeout(() => {
491
+ if (childHandle && deps.bus?.hasPendingAskFrom(childHandle)) {
492
+ armIdle();
493
+ return;
494
+ }
495
+ requestCancellation("idle");
496
+ }, watchdogMs);
497
+ idleTimer.unref?.();
498
+ };
499
+
500
+ // Hard wall-clock cap: armed ONCE, never reset by events — a definite lifetime ceiling
501
+ // that settles a busy-but-non-converging child (the idle watchdog above never catches).
502
+ // It starts before session construction, matching the child engine's process lifetime cap.
503
+ // Applies unconditionally — including to a coaching child legitimately blocked on a reply.
504
+ const hardMs = isPositiveFiniteMs(deps.hardTimeoutMs) ? deps.hardTimeoutMs : 0;
505
+ let hardTimer: ReturnType<typeof setTimeout> | undefined;
506
+ const armHard = (): void => {
507
+ if (hardMs <= 0 || hardTimer) return;
508
+ hardTimer = setTimeout(() => requestCancellation("hard"), hardMs);
509
+ hardTimer.unref?.();
510
+ };
511
+ const disarmHard = (): void => {
512
+ if (hardTimer) {
513
+ clearTimeout(hardTimer);
514
+ hardTimer = undefined;
515
+ }
516
+ };
517
+
518
+ // Startup deadline (mirrors the child engine): one window from construction to the first
519
+ // real progress (completed turn / tokens / streamed output). Stays armed for every child,
520
+ // including one that may block on a supervisor reply before ever emitting anything — the
521
+ // bus consult below re-arms it for as long as that ask is genuinely pending (R9). During
522
+ // construction there is no session yet, so no ask can possibly be pending: the window
523
+ // behaves exactly as it always has while initializing.
524
+ const startupMs = isPositiveFiniteMs(deps.startupTimeoutMs) ? deps.startupTimeoutMs : 0;
525
+ let startupProgressed = false;
526
+ let startupTimer: ReturnType<typeof setTimeout> | undefined;
527
+ const disarmStartup = (): void => {
528
+ if (startupTimer) {
529
+ clearTimeout(startupTimer);
530
+ startupTimer = undefined;
531
+ }
532
+ };
533
+ const armStartup = (): void => {
534
+ disarmStartup();
535
+ if (startupMs <= 0) return;
536
+ startupTimer = setTimeout(() => {
537
+ if (startupProgressed) return;
538
+ if (childHandle && deps.bus?.hasPendingAskFrom(childHandle)) {
539
+ armStartup();
540
+ return;
541
+ }
542
+ requestCancellation("startup");
543
+ }, startupMs);
544
+ startupTimer.unref?.();
545
+ };
546
+ const noteStartupProgress = (): void => {
547
+ if (startupProgressed) return;
548
+ const s = snapshot(state);
549
+ if (s.turns > 0 || s.tokens > 0 || s.output.length > 0) {
550
+ startupProgressed = true;
551
+ disarmStartup();
552
+ }
553
+ };
554
+
555
+ // The Pi resource loader currently has no cancellation API. `armIdle`/`armHard`/`armStartup`
556
+ // (below) already start before the factory is invoked and stay held until the factory
557
+ // promise itself settles, so a timed-out run may return to its semaphore caller while a
558
+ // late session is disposed and a late rejection is consumed by this handled promise.
559
+ const onAbort = (): void => requestCancellation("abort");
560
+ if (signal) {
561
+ if (signal.aborted) requestCancellation("abort");
562
+ else signal.addEventListener("abort", onAbort, { once: true });
563
+ }
564
+ if (cancellationRequested) {
565
+ if (signal) signal.removeEventListener("abort", onAbort);
566
+ releaseHandle();
567
+ return {
568
+ agent: spec.agent,
569
+ output: "",
570
+ usage: emptyUsage(),
571
+ ok: false,
572
+ error: `${tag} agent aborted`,
573
+ ...(resolvedRef ? { modelUsed: resolvedRef } : {}),
574
+ failureKind: "abort",
575
+ };
576
+ }
577
+
578
+ const safeDispose = (late: InProcSession): void => {
579
+ try {
580
+ late.dispose();
581
+ } catch {
582
+ // A late session has no consumer left to report disposal errors to; never turn
583
+ // cleanup into an unhandled rejection or strand the global guard.
584
+ }
585
+ };
586
+ type InitializationOutcome = { session?: InProcSession; error?: unknown };
587
+ let initializationSettled = false;
588
+
589
+ pushDisableGuard();
590
+ armHard();
591
+ armIdle();
592
+ armStartup();
593
+ const initialization = Promise.resolve()
594
+ .then(() => createSession({ ...sessionOpts, signal: initController.signal }))
595
+ .then(
596
+ (created) => {
597
+ if (cancellationRequested) {
598
+ safeDispose(created);
599
+ return {};
600
+ }
601
+ initializedSession = created;
602
+ return { session: created };
603
+ },
604
+ (error): InitializationOutcome => ({ error }),
605
+ )
606
+ .then((outcome) => {
607
+ initializationSettled = true;
608
+ // Do not pop this guard at the timeout boundary: the original factory may still
609
+ // be loading recursive extensions under PI_PERSONA_DISABLE=1.
610
+ popDisableGuard();
611
+ return outcome;
612
+ });
613
+
614
+ const initializationOutcome = await Promise.race([
615
+ initialization,
616
+ abortGate.then(() => undefined as InitializationOutcome | undefined),
617
+ ]);
618
+ if (cancellationRequested || !initializationOutcome || !initializationOutcome.session) {
619
+ if (cancellationRequested && initializedSession) {
620
+ const cancelledSession = initializedSession;
621
+ initializedSession = undefined;
622
+ safeDispose(cancelledSession);
623
+ }
624
+ if (signal) signal.removeEventListener("abort", onAbort);
625
+ disarmIdle();
626
+ disarmHard();
627
+ disarmStartup();
628
+ releaseHandle();
629
+ if (initializationOutcome && "error" in initializationOutcome && !cancellationRequested) throw initializationOutcome.error;
630
+ const initError = hardTimedOut
631
+ ? `${tag} agent exceeded the ${hardMs}ms hard cap during session initialization`
632
+ : timedOut
633
+ ? `${tag} agent timed out during session initialization — no events for ${watchdogMs}ms`
634
+ : startupTimedOut
635
+ ? `${tag} agent produced no output within the ${startupMs}ms startup window while initializing the session`
636
+ : `${tag} agent aborted`;
637
+ const cleanupHint = initializationSettled ? "" : "; initialization cancellation requested, but the host loader may still be unwinding. If initialization keeps hanging, restart Pi and use PI_PERSONA_ENGINE=child";
638
+ return {
639
+ agent: spec.agent,
640
+ output: "",
641
+ usage: emptyUsage(),
642
+ ok: false,
643
+ error: `${initError}${cleanupHint}`,
644
+ ...(resolvedRef ? { modelUsed: resolvedRef } : {}),
645
+ failureKind: hardTimedOut || timedOut || startupTimedOut ? "timeout" : "abort",
646
+ };
647
+ }
648
+ session = initializationOutcome.session;
649
+
650
+ // From here to the run's own try/finally the child is live but its cleanup is not
651
+ // armed yet: the subscription, the caller-supplied `onSteerable`, and the delivery
652
+ // bridge's initial flush all run outside it. A throw in any of them must release the
653
+ // handle AND the session, never leave a phantom peer plus an orphaned live session.
654
+ let unsub: (() => void) | undefined;
655
+ let unsubBridge: (() => void) | undefined;
656
+ let steerable = true;
657
+ // Calls whose start crossed the progress seam but whose end has not. The tool NAME rides
658
+ // beside the id because the synthetic close below has to carry it, and stream.ts tracks
659
+ // only the ids. Mirrors the child engine's `openToolCalls`.
660
+ const openToolCalls = new Map<string, string>();
661
+ // One progress tick, routed exactly as the subscription routes them: the per-call sink
662
+ // takes the AgentProgress shape (no `turns`), else the construction-time sink.
663
+ const emitProgress = (snap: ProgressSnapshot): void => {
664
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
665
+ else if (deps.onProgress) deps.onProgress(snap);
666
+ };
667
+ let contractDef: ContractDef | undefined = requestedDef;
668
+ let task: string;
669
+ try {
670
+ unsub = session.subscribe((ev) => {
671
+ armIdle(); // any event = activity → reset the idle clock
672
+ const toolEvent = applyEvent(state, ev);
673
+ // Track the open calls so an abandoned one can be closed once the run settles.
674
+ if (toolEvent) {
675
+ if (toolEvent.phase === "start") openToolCalls.set(toolEvent.callId, toolEvent.name);
676
+ else openToolCalls.delete(toolEvent.callId);
677
+ }
678
+ noteStartupProgress(); // first real progress cancels the startup deadline
679
+ emitProgress(snapshot(state, toolEvent));
680
+ });
681
+
682
+ // Steering: the in-process engine can inject a user message into the running
683
+ // agent — the v0.4 payoff the one-shot child engine can't do.
684
+ onSteerable?.((text) => {
685
+ if (!steerable) return false;
686
+ const trimmed = text.trim();
687
+ if (!trimmed) return false;
688
+ try {
689
+ session.agent.steer({ role: "user", content: [{ type: "text", text: trimmed }] });
690
+ return true;
691
+ } catch {
692
+ return false;
693
+ }
694
+ });
695
+
696
+ // Delivery bridge: bus messages addressed to this child are steered into its live
697
+ // session as fenced, attributed user turns — attribution stays OUTSIDE the fence so
698
+ // a malicious payload cannot spoof its sender. Serves sibling `contact_peer` sends
699
+ // AND the supervisor's `intercom send` (previously a dead letter: nothing ever
700
+ // drained a child inbox). Flush once on subscribe to catch a message that raced
701
+ // registration (the handle registers before the session finishes building).
702
+ if (childHandle && deps.bus) {
703
+ const b = deps.bus;
704
+ const self = childHandle;
705
+ const deliver = (): void => {
706
+ for (const env of b.takeWhere(self, (e) => !e.expectsReply)) {
707
+ const from = env.from === supervisorHandle ? "your supervisor" : `peer ${peerLabels.get(env.from) ?? env.from}`;
708
+ // deliver() runs on the initial flush (BEFORE the run's try/finally below) and
709
+ // inside the bus.onMessage listener (a SENDER's bus.send call stack) — an
710
+ // uncaught steer() throw here would leak the bus registration/observer, skip
711
+ // engine.run cleanup, or propagate into the sender's contact_peer call. Drop the
712
+ // envelope instead; it is lost, not redelivered (mirrors "gone" semantics).
713
+ try {
714
+ session.agent.steer({ role: "user", content: [{ type: "text", text: attributeInbound(from, env.text) }] });
715
+ } catch (e) {
716
+ if (process.env.PI_PERSONA_DEBUG) {
717
+ process.stderr.write(`[pi-persona] delivery bridge steer failed for ${self} (envelope from ${env.from} dropped): ${e instanceof Error ? e.message : String(e)}\n`);
718
+ }
719
+ continue;
720
+ }
721
+ // Transparency tick: surface the delivery on the run's progress line (agent tree).
722
+ // Mirrors the main subscription's fallback (line ~332): per-call onProgress wins
723
+ // (AgentProgress — no `turns`), else the construction-time deps.onProgress
724
+ // (ProgressSnapshot — built from snapshot(state), `turns` included) — the async
725
+ // launch path (extension.ts) only has the latter.
726
+ const snap = snapshot(state);
727
+ const activity = `✉ from ${env.from}`;
728
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, activity });
729
+ else if (deps.onProgress) deps.onProgress({ ...snap, activity });
730
+ }
731
+ };
732
+ unsubBridge = b.onMessage((env) => {
733
+ if (env.to === self && !env.expectsReply) deliver();
734
+ });
735
+ deliver();
736
+ }
737
+
738
+ // The agent's persona is now the session's appended system prompt (see the
739
+ // resource loader); the user turn carries just the task (+ any skills to load,
740
+ // + the output-contract format when one is requested — resolved and pinned HERE
741
+ // so the def that instructs the member is the def that validates it below).
742
+ const skillsPreamble =
743
+ spec.skills && spec.skills.length > 0
744
+ ? `Load these skills before starting (use the nearest affine if one is missing): ${spec.skills.join(", ")}.\n\n`
745
+ : "";
746
+ const contractTail = contractDef ? `\n\n${contractInstructions(contractDef)}` : "";
747
+ task = `${skillsPreamble}Task: ${spec.task}${contractTail}`;
748
+ } catch (e) {
749
+ disarmIdle();
750
+ disarmHard();
751
+ disarmStartup();
752
+ if (signal) signal.removeEventListener("abort", onAbort);
753
+ unsubBridge?.();
754
+ unsub?.();
755
+ releaseHandle();
756
+ steerable = false;
757
+ session.dispose();
758
+ throw e;
759
+ }
760
+
761
+ // Capture a thrown error (e.g. provider API 400 before any stream event fires,
762
+ // like model_not_supported) so it survives the finally-cleanup and can be
763
+ // folded into the AgentResult below. Otherwise the stream state stays empty
764
+ // and the user sees an opaque "agent failed (unknown)".
765
+ let thrownError: string | undefined;
766
+ try {
767
+ armIdle(); // start the idle clock (reset on every session event)
768
+ armHard(); // start the lifetime ceiling (never reset)
769
+ const runToIdle = async (): Promise<void> => {
770
+ await session.prompt(task);
771
+ // An abort may have won while prompt() was resolving. Do not start another host wait
772
+ // after that point; the outer race is already taking us through cleanup.
773
+ if (!aborted && !timedOut && !hardTimedOut && !startupTimedOut) await session.agent.waitForIdle();
774
+ };
775
+ if (aborted || timedOut || hardTimedOut || startupTimedOut) await abortGate;
776
+ else await Promise.race([runToIdle(), abortGate]);
777
+ } catch (e) {
778
+ thrownError = e instanceof Error ? e.message : String(e);
779
+ } finally {
780
+ disarmIdle();
781
+ disarmHard();
782
+ disarmStartup();
783
+ if (signal) signal.removeEventListener("abort", onAbort);
784
+ unsubBridge?.();
785
+ releaseHandle();
786
+ unsub?.();
787
+ steerable = false;
788
+ session.dispose();
789
+ }
790
+
791
+ const ok =
792
+ !aborted && !timedOut && !hardTimedOut && !startupTimedOut && !thrownError && state.stopReason !== "error" && state.stopReason !== "aborted";
793
+ // A session torn down mid-flight (abort, timeout, provider throw) never delivers the
794
+ // tool_execution_end for whatever it was running: pi emits that end only once the tool has
795
+ // unwound (a macrotask or more later, e.g. bash killing its tree then awaiting close), by which
796
+ // time the teardown above has dropped the listener. Close each abandoned call here, as the child
797
+ // engine's finish() does — otherwise the consumer shows that tool "running" forever and its
798
+ // telemetry record stays pinned. Two rules keep the synthesis honest:
799
+ // * a run that SETTLED NORMALLY is left alone. pi notifies subscribers behind the agent
800
+ // (_handleAgentEvent awaits the extension runner before it fans out), so a green run's last
801
+ // end can land after waitForIdle() resolved — closing it `failed` would report a tool that
802
+ // actually completed as failed. Leaving it open is the older, honest gap.
803
+ // * it runs AFTER the teardown and each tick is guarded, because emitProgress calls consumer
804
+ // code: inside the finally an onProgress throw skipped the handle release, the unsubscribes
805
+ // and dispose(). Dropping the tick mirrors deliver()'s contract for a throwing steer().
806
+ if (!ok) {
807
+ for (const [callId, name] of openToolCalls) {
808
+ try {
809
+ emitProgress(snapshot(state, { phase: "end", callId, name, failed: true }));
810
+ } catch {
811
+ /* the consumer's sink threw; the tick is lost, the run still settles */
812
+ }
813
+ }
814
+ }
815
+
816
+ const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
817
+ // A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
818
+ if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
819
+ // The deadline cannot tell the two causes apart, so the message names both: claiming
820
+ // "it never started" would misdirect the operator of a leg that did start and was
821
+ // simply waiting on a slow first response (mirrors the child engine's wording).
822
+ else if (startupTimedOut)
823
+ result.error = `${tag} agent produced no output within the ${startupMs}ms startup window — either it never started (a stalled init) or its first provider response was slower than the window (a queued or rate-limited provider, a cold local model); raise or disable the window with PI_PERSONA_AGENT_STARTUP_MS (0 disables)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
824
+ else if (hardTimedOut) result.error = `${tag} agent exceeded the ${hardMs}ms hard cap${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
825
+ else if (aborted) result.error = `${tag} agent aborted`;
826
+ else if (state.errorMessage) result.error = `${tag} ${state.errorMessage}`;
827
+ else if (thrownError) result.error = `${tag} ${thrownError}`;
828
+ else if (!ok) result.error = `${tag} agent failed (${state.stopReason ?? "unknown"})`;
829
+ // Classify the failure so the fallback decorator can reroute ONLY provider errors,
830
+ // never an abort/timeout/hard-cap/agent miss. A stream `error` stop reason is pi's
831
+ // own report of an API failure → provider. A THROWN error is not automatically a
832
+ // provider fault (bugs and tool failures throw too) — classify by its text.
833
+ if (!ok)
834
+ result.failureKind =
835
+ timedOut || hardTimedOut || startupTimedOut
836
+ ? "timeout"
837
+ : aborted
838
+ ? "abort"
839
+ : state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
840
+ ? "provider"
841
+ : "agent";
842
+
843
+ if (contractDef) {
844
+ const v = parseAndValidate(state.output, contractDef);
845
+ if (v.value) result.structured = v.value;
846
+ if (!v.ok) {
847
+ result.ok = false;
848
+ // The cause of death (timeout/abort/provider) wins over the contract miss —
849
+ // naming "not valid JSON" over an empty output hides the real failure.
850
+ if (v.error && result.error === undefined) result.error = v.error;
851
+ if (result.failureKind === undefined) result.failureKind = "contract";
852
+ }
853
+ }
854
+ return result;
855
+ },
856
+ };
857
+ }