@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,796 @@
1
+ /**
2
+ * Host lifecycle hooks: session, settlement, prompt composition, tool gate, mandatory orchestration.
3
+ */
4
+ import { existsSync } from "node:fs";
5
+
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+
8
+ import type { AgentConfig } from "../agents/agent.ts";
9
+ import { buildExocomBrief, RUNTIME_CONTEXT_GUIDANCE } from "../core/brief.ts";
10
+ import { canCallTool, canDelegateTo, canFanOut } from "../core/capabilities.ts";
11
+ import type { PiPersonaConfig } from "../core/config.ts";
12
+ import { fenceUntrusted } from "../core/fence.ts";
13
+ import type { DelegationNudge, PersistenceNudge } from "../core/nudge.ts";
14
+ import { buildSessionAnchor } from "../core/time.ts";
15
+ import { readClockSnapshot } from "./clock.ts";
16
+ import { type AsyncRun, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
17
+ import { constrainedTurnAllows } from "../exocom/gate.ts";
18
+ import type { LedgerClaim } from "../exocom/ledger.ts";
19
+ import type { DisplayPeer } from "../exocom/plane.ts";
20
+ import { peerClaimFor, WRITE_TOOLS, writeWarningKey, writeWarningReason } from "../exocom/write-guard.ts";
21
+ import {
22
+ canonicalExocomTelemetryTargets,
23
+ piPersonaToolErrorPatch,
24
+ sendPersonaFollowUp,
25
+ type PendingAsk,
26
+ } from "./shared.ts";
27
+ import type { TimerEntry } from "../core/timer.ts";
28
+ import type { MonitorSession } from "../monitor/session.ts";
29
+ import type { FlowSpec } from "../orchestration/flow.ts";
30
+ import { resolveStrategyName } from "../persona/orchestrate.ts";
31
+ import type { PersonaController, PersonaHost } from "../persona/controller.ts";
32
+ import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
33
+ import type { PersonaConfigStore } from "../persona/config-store.ts";
34
+ import { TELEMETRY_EVENT_NAME, type TelemetryEvent } from "../telemetry/contract.ts";
35
+ import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
36
+ import type { AgentTree, AgentNode } from "../ui/agent-tree.ts";
37
+ import type { FailureKind } from "../orchestration/types.ts";
38
+ import type { ExocomInstall } from "../exocom/install.ts";
39
+ import type { SessionIdentity } from "./identity.ts";
40
+
41
+ type MandatoryOutcome = { label: string; output: string; ok: boolean; error?: string; failureKind?: FailureKind };
42
+
43
+ export interface HookHost {
44
+ readonly identity: SessionIdentity;
45
+ config: PiPersonaConfig;
46
+ controller: PersonaController;
47
+ personaHost: PersonaHost;
48
+ get lastCtx(): ExtensionContext | undefined;
49
+ set lastCtx(value: ExtensionContext | undefined);
50
+ get disposed(): boolean;
51
+ set disposed(value: boolean);
52
+ get orchestrating(): boolean;
53
+ set orchestrating(value: boolean);
54
+ get processingDeferredOrchestration(): boolean;
55
+ set processingDeferredOrchestration(value: boolean);
56
+ get pendingOrchestration(): MandatoryOutcome | undefined;
57
+ set pendingOrchestration(value: MandatoryOutcome | undefined);
58
+ readonly MAX_DEFERRED_ORCHESTRATIONS: number;
59
+ readonly deferredOrchestrations: Array<{ task: string; orchestration: OrchestrationGrammar }>;
60
+ get telemetry(): TelemetryProducer | undefined;
61
+ set telemetry(value: TelemetryProducer | undefined);
62
+ readonly telemetryToolStartedAt: Map<string, { startedAt: number; name: string }>;
63
+ readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number }>>;
64
+ readonly telemetryIntercomPending: Map<string, { kind: string; to: string; size: number; replyTo?: string }>;
65
+ readonly lastAgentProjection: Map<string, string>;
66
+ readonly telemetryAskSenders: Map<string, string>;
67
+ readonly agentTree: AgentTree;
68
+ telemetryAgent(node: AgentNode): TelemetryAgentInput;
69
+ delegationNudge: DelegationNudge;
70
+ persistenceNudge: PersistenceNudge;
71
+ appendNudgeEntry(content: string): void;
72
+ completionNotifier: IdleCoalescingNotifier<AsyncRun>;
73
+ intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
74
+ timerNotifier: IdleCoalescingNotifier<TimerEntry>;
75
+ timerScheduler: { cancelAll(): void };
76
+ monitors: MonitorSession;
77
+ peekWatcher: PeekWatcher;
78
+ stopPeek(): void;
79
+ stopRegistry: Map<string, () => void>;
80
+ stopRequested: Set<string>;
81
+ steerRegistry: Map<string, unknown>;
82
+ broker: { close(): Promise<void> };
83
+ get spineText(): string;
84
+ delegationBrief(ctx: ExtensionContext): string | undefined;
85
+ get agents(): AgentConfig[];
86
+ get personas(): Persona[];
87
+ reload(cwd: string): void;
88
+ readConfigStore(): PersonaConfigStore;
89
+ get personaConfigs(): PersonaConfigStore;
90
+ set personaConfigs(value: PersonaConfigStore);
91
+ readRememberedPersona(): string | undefined;
92
+ runSeed(force: boolean): { copied: unknown[] };
93
+ seedMarker(): string;
94
+ personaDataDir(): string;
95
+ reportSeedSourceCollisions(ctx: ExtensionContext, result: { copied: unknown[] }): void;
96
+ refreshSpineAfterSeed(): void;
97
+ inspectEnabledLegacySpines(): void;
98
+ reportSeedMigration(ctx: ExtensionContext, result: unknown): void;
99
+ seedMigration: unknown;
100
+ reportDataRootMigration(ctx: ExtensionContext): void;
101
+ reportSpineWarning(ctx: ExtensionContext): void;
102
+ reportDefinitionCollisions(ctx: ExtensionContext): void;
103
+ userAgentDir(): string;
104
+ loadFlow(cwd: string, name: string): { ok: true; flow: FlowSpec } | { ok: false; error: string } | undefined;
105
+ runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string): Promise<{ output: string; ok: boolean; cancelled?: boolean; error?: string; failureKind?: FailureKind }>;
106
+ runStrategyVisible(ctx: ExtensionContext, orch: OrchestrationGrammar, task: string, label: string): Promise<{ output: string; ok: boolean; error?: string; failureKind?: FailureKind } | undefined>;
107
+ personaGrammarError(err: unknown): string;
108
+ intercomRecipient(input: Record<string, unknown>, askId: string | undefined): string;
109
+ idleDelivery: unknown;
110
+ drainBusBlock: () => string;
111
+ childUsage: { reset(): void };
112
+ publishPersonaCost: () => void;
113
+ }
114
+
115
+ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstall): void {
116
+ // ── lifecycle ─────────────────────────────────────────────────────────────
117
+ pi.on("session_start", async (_event, ctx) => {
118
+ h.lastCtx = ctx;
119
+ h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
120
+ h.childUsage.reset();
121
+ h.publishPersonaCost();
122
+ // Opt-in only (PI_PERSONA_SEED=on): auto-install the bundled defaults once. Default is off —
123
+ // a fresh install shows no h.personas until `/persona seed` or `/persona restore`.
124
+ if (h.config.seed && !existsSync(h.seedMarker())) {
125
+ try {
126
+ const r = h.runSeed(false);
127
+ h.reportSeedSourceCollisions(ctx, r);
128
+ // Resolution happened before session_start, when a fresh install had no user copy. Refresh
129
+ // now so this very process uses what auto-seed just created and reports only post-seed state.
130
+ h.refreshSpineAfterSeed();
131
+ if (ctx.hasUI && r.copied.length > 0) {
132
+ ctx.ui.notify(`pi-persona: seeded ${r.copied.length} default(s) to ${h.personaDataDir()} — edit them freely; /persona restore brings back the originals.`, "info");
133
+ }
134
+ } catch {
135
+ // Seeding is multi-file: an error can arrive after the prompt itself was copied. Re-resolve
136
+ // and retry only enabled legacy roles so partial progress is visible in this same process.
137
+ h.inspectEnabledLegacySpines();
138
+ }
139
+ }
140
+ h.reportSeedMigration(ctx, h.seedMigration);
141
+ h.reportDataRootMigration(ctx); // the one-time storage-root move ran at activation; this is its voice
142
+ h.reportSpineWarning(ctx);
143
+ h.reload(ctx.cwd);
144
+ h.identity.start(ctx); // restore session identity before persona activation can join Exocom
145
+ h.reportDefinitionCollisions(ctx);
146
+ h.personaConfigs = h.readConfigStore();
147
+ // Restore order: --persona flag > env pin (PI_PERSONA_DEFAULT) > remembered-on-disk. Read-only.
148
+ const flagPersona = ((pi.getFlag("persona") as string) || "").trim();
149
+ const remembered = flagPersona || h.config.defaultPersona || h.readRememberedPersona();
150
+ const target = remembered ? h.personas.find((p) => p.name === remembered) : undefined;
151
+ if (target) await h.controller.activate(target);
152
+ else {
153
+ // A name that doesn't resolve leaves the session with NO persona at all, whichever source it
154
+ // came from — and silence there reads as "it's active" while nothing is (downstream tooling
155
+ // keyed on the remembered name, e.g. per-persona memory, then serves a persona that was never
156
+ // activated). An EXPLICIT `--persona` is a direct instruction, so it stays an error; an env
157
+ // pin or a stale remembered name is a warning. The marker itself is deliberately NOT cleared:
158
+ // h.personas are discovered per-cwd, so a name that is missing here may be present in the
159
+ // project the user came from. The model (`--model`) and effort (`--thinking`) are pi's own
160
+ // flags — pi validates those.
161
+ if (remembered) {
162
+ const names = h.personas.map((p) => p.name).sort().join(", ") || "(none installed — run /persona seed)";
163
+ const source = flagPersona ? "--persona" : h.config.defaultPersona ? "PI_PERSONA_DEFAULT" : "remembered persona";
164
+ const msg = `pi-persona: ${source} "${remembered}" is not an installed persona. Available: ${names}`;
165
+ if (ctx.hasUI) ctx.ui.notify(msg, flagPersona ? "error" : "warning");
166
+ else process.stderr.write(`${msg}\n`);
167
+ }
168
+ h.personaHost.setStatus(h.controller.activePersona?.label);
169
+ }
170
+ await exocom.reconcile(ctx); // after persona activation, so plane + active tools share one gate
171
+ await h.telemetry?.stop("session-replaced");
172
+ h.telemetry = undefined;
173
+ const sessionManager = (ctx as ExtensionContext & { sessionManager?: { getSessionId?: () => string } }).sessionManager;
174
+ const sessionId = sessionManager?.getSessionId?.();
175
+ if (sessionId) {
176
+ const eventBus = (pi as unknown as { events?: { emit?: (name: string, event: TelemetryEvent) => void } }).events;
177
+ h.telemetry = new TelemetryProducer({
178
+ agentDir: h.userAgentDir(),
179
+ cwd: ctx.cwd,
180
+ sessionId,
181
+ emit: (event) => eventBus?.emit?.(TELEMETRY_EVENT_NAME, event),
182
+ heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
183
+ ...(process.env.PI_PERSONA_DEBUG
184
+ ? { onError: (error: unknown) => { process.stderr.write(`[pi-persona] telemetry: ${error instanceof Error ? error.message : String(error)}\n`); } }
185
+ : {}),
186
+ });
187
+ h.telemetry.start(exocom.currentTelemetryInstance(ctx));
188
+ // Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
189
+ // published — a session start that follows no teardown carries no stale projection.
190
+ h.lastAgentProjection.clear();
191
+ for (const node of h.agentTree.snapshot()) {
192
+ h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
193
+ h.telemetry.publishAgentAdded(h.telemetryAgent(node));
194
+ }
195
+ exocom.publishTelemetryPeers();
196
+ }
197
+ });
198
+
199
+ pi.on("session_shutdown", async (event, ctx) => {
200
+ h.lastCtx = ctx;
201
+ h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
202
+ h.deferredOrchestrations.length = 0;
203
+ h.pendingOrchestration = undefined;
204
+ h.stopPeek(); // h.reload-hygiene: never leak the idle-peek timer across sessions
205
+ h.peekWatcher.reset(); // …nor a stale "already surfaced this leg as stuck" set into the next session
206
+ h.completionNotifier.cancel(); // …nor the coalesced-delivery flush timers
207
+ h.intercomNotifier.cancel();
208
+ exocom.notifier?.cancel(); // …nor a late exocom follow-up (M1) — cancelled BEFORE exocom.stop tears down the plane below
209
+ exocom.waitNotifier?.cancel();
210
+ h.timerScheduler.cancelAll(); // …nor any armed alarms (never wake the next session)
211
+ h.timerNotifier.cancel();
212
+ h.monitors.cancelAll();
213
+ // This instance is being torn down (a h.reload/new/resume rebinds a fresh one); abort in-flight
214
+ // sub-h.agents and reset control state so nothing is left orphaned or rendered stale.
215
+ for (const abort of [...h.stopRegistry.values()]) {
216
+ try {
217
+ abort();
218
+ } catch {
219
+ /* ignore */
220
+ }
221
+ }
222
+ h.stopRegistry.clear();
223
+ h.stopRequested.clear();
224
+ h.steerRegistry.clear();
225
+ h.agentTree.clear();
226
+ h.personaHost.setStatus(undefined);
227
+ // Queued, not called bare: a shutdown that lands while session_start's own start is still
228
+ // pending would otherwise see `exocom.plane` unset, no-op, and let that start bind a socket
229
+ // and register an entry for a session that no longer exists. Idempotent either way — off
230
+ // (or never started) ⇒ a no-op.
231
+ await exocom.queue(() => exocom.stop());
232
+ // Broker teardown (spec B1/B5): idempotent — a session that never built a broker-backed
233
+ // child engine (flag off, or on but unused) never started a host, so this is a no-op.
234
+ // `SupervisorBroker.close()` is itself best-effort (never blocks shutdown on a teardown error).
235
+ await h.broker.close();
236
+ await h.telemetry?.stop(event?.reason);
237
+ h.telemetry = undefined;
238
+ h.telemetryToolStartedAt.clear();
239
+ h.telemetryExocomPending.clear();
240
+ h.telemetryIntercomPending.clear();
241
+ h.telemetryAskSenders.clear(); // …and no ask attribution leaks into the next session
242
+ h.lastAgentProjection.clear();
243
+ });
244
+
245
+ // Pi marks the session idle before emitting agent_settled. A deferred mandatory input owns this
246
+ // idle window first; its replay starts the next run and the remaining FIFO entries follow one per
247
+ // settlement. With no deferred input, flush ordinary wakes immediately.
248
+ pi.on("agent_settled", async () => {
249
+ if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
250
+ // Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
251
+ // after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
252
+ // would otherwise stay "running" until session_shutdown (press Esc mid-fan-out and every
253
+ // call that had not yet started is orphaned). The turn boundary is where they end.
254
+ for (const [callId, open] of h.telemetryToolStartedAt) {
255
+ h.telemetry?.publish("tool.finished", {
256
+ callId,
257
+ agentId: "supervisor",
258
+ name: open.name,
259
+ status: "failed",
260
+ durationMs: Math.max(0, Date.now() - open.startedAt),
261
+ });
262
+ }
263
+ h.telemetryToolStartedAt.clear(); // a late result for a drained call must not close it twice
264
+ // An exocom send orphaned on that same path left its recipients at "queued" forever. They are
265
+ // reported failed, never delivered: the call never reached a result, so nothing observed a send.
266
+ for (const pending of h.telemetryExocomPending.values()) {
267
+ for (const message of pending) h.telemetry?.publish("message.sent", {
268
+ id: message.id,
269
+ channel: "exocom",
270
+ // A session id, like every other exocom publish site — never an agentId literal in this slot.
271
+ from: h.lastCtx ? exocom.telemetrySessionId(h.lastCtx) : `legacy-${process.pid}`,
272
+ to: message.sessionId,
273
+ kind: "message",
274
+ status: "failed",
275
+ expectsReply: false,
276
+ size: message.size,
277
+ });
278
+ }
279
+ h.telemetryExocomPending.clear();
280
+ for (const [callId, message] of h.telemetryIntercomPending) {
281
+ h.telemetry?.publish(message.kind === "reply" ? "message.replied" : "message.sent", {
282
+ id: callId,
283
+ channel: "intercom",
284
+ from: "supervisor",
285
+ to: message.to,
286
+ kind: message.kind,
287
+ status: "failed",
288
+ expectsReply: false,
289
+ size: message.size,
290
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
291
+ });
292
+ }
293
+ h.telemetryIntercomPending.clear();
294
+ if (await processNextDeferredOrchestration()) return;
295
+ h.completionNotifier.kick();
296
+ h.intercomNotifier.kick();
297
+ h.timerNotifier.kick();
298
+ h.monitors.kick();
299
+ exocom.notifier?.kick();
300
+ exocom.waitNotifier?.kick();
301
+ });
302
+
303
+ pi.on("before_agent_start", (event, ctx) => {
304
+ h.lastCtx = ctx;
305
+ h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(ctx), status: "active" });
306
+ // The spine lifts persona-less turns too: with nothing active the composition is Pi's base
307
+ // prompt + the layer. Off ⇒ `h.spineText` is empty and this is `event.systemPrompt` itself.
308
+ const noPersona = h.spineText ? `${event.systemPrompt}\n\n${h.spineText}` : event.systemPrompt;
309
+ let prompt = h.controller.composePrompt(event.systemPrompt, h.spineText) ?? noPersona;
310
+ // Stable system-level interpretation of runtime metadata. Never append an acknowledgement
311
+ // request to the conversation: that would recreate the old identity pseudo-turn loop.
312
+ prompt = `${prompt}\n\n${RUNTIME_CONTEXT_GUIDANCE}`;
313
+ // The session time anchor (core/time.ts). Unconditional, unlike both briefs below: a supervisor
314
+ // that has been working for hours needs to know it whether or not h.agents or peers exist. It goes
315
+ // in the SYSTEM prompt because the prompt is re-composed and re-sent every turn rather than
316
+ // summarized, so an anchor placed here cannot be compacted away — the same fact in the
317
+ // conversation tail would be. Its start comes from the session HEADER, the first entry of the
318
+ // append-only session file, so after /resume it is still the ORIGINAL start: that is what makes it
319
+ // answer "how long have I been on this problem" across restarts and not merely across turns.
320
+ // Placed BEFORE the briefs so the standing hand-off default keeps the last word at the tail; the
321
+ // elapsed reading remains coarse to preserve caching; the precise clock goes in a new
322
+ // conversation message below, and timer now can refresh it within a long tool loop.
323
+ const now = Date.now();
324
+ const anchor = buildSessionAnchor(ctx.sessionManager?.getHeader?.() ?? null, now);
325
+ if (anchor) prompt = `${prompt}\n\n${anchor}`;
326
+ const wakeCaps = h.controller.capabilities;
327
+ const activeWakeTools = new Set(pi.getActiveTools());
328
+ const wakeAllows = (name: string) => activeWakeTools.has(name) && (!wakeCaps || canCallTool(wakeCaps, name));
329
+ const wakeGuidance: string[] = [];
330
+ if (wakeAllows("timer")) wakeGuidance.push("timer now reads the current clock; timer arm schedules a fixed-time wake (absolute times require a timezone).");
331
+ if (wakeAllows("monitor") && wakeAllows("bash")) wakeGuidance.push("monitor watches a job or event-producing program: mode=exit for job completion, mode=output for file/log/system changes reported as stdout lines. Filter changes in the producer; set a useful deadline and event count, and cancel the watch when its purpose ends.");
332
+ if (wakeGuidance.length) prompt = `${prompt}\n\n[Session wakes] ${wakeGuidance.join(" ")} Continue independent work while waiting; end your turn when only the event remains. These wakes require this Pi session to stay open. Use existing automatic sub-agent/peer notifications for their results; avoid duplicate watches or polling loops.`;
333
+ const brief = h.delegationBrief(ctx);
334
+ if (brief) prompt = `${prompt}\n\n${brief}`;
335
+ // Per-turn exocom peer AWARENESS (mirrors the delegation brief above): regenerated from the
336
+ // live registry every turn so it cannot desync, and gated implicitly — exocom.plane is only
337
+ // set when exocom is on AND canUseBus, so this is a no-op (undefined ⇒ no prompt change)
338
+ // whenever exocom is off, matching every other opt-in surface in this file.
339
+ if (exocom.plane) {
340
+ let peers: DisplayPeer[] | undefined;
341
+ let pendingBlock: string | undefined;
342
+ let ledgerFailure: string | undefined;
343
+ try {
344
+ peers = exocom.plane.listPeers();
345
+ } catch {
346
+ // Peer awareness is opportunistic. A transient registry I/O failure must not abort
347
+ // Pi's turn or prevent the rest of the system prompt from reaching the model.
348
+ }
349
+ if (exocom.ledgerFile) {
350
+ try {
351
+ pendingBlock = exocom.pendingAskPrompt(ctx);
352
+ } catch (error) {
353
+ ledgerFailure = `[pi-persona] Exocom ledger unavailable. Treat work ownership and pending asks as unresolved; do not mutate or delegate until storage recovers. (${error instanceof Error ? error.message : String(error)})`;
354
+ }
355
+ }
356
+ const xcaps = h.controller.capabilities;
357
+ // Holding the bus says nothing about `delegate` (canUseBus keys off `intercom` alone), so
358
+ // read the persona the way h.delegationBrief does — absent capabilities ⇒ unrestricted — but
359
+ // gate on a REACHABLE target, which is stricter than h.delegationBrief: that one still renders
360
+ // (with `installedCount`, to say "your allowlist filtered everything away") when fan-out is
361
+ // allowed and no target survives, whereas urging a peer-vs-sub-agent split with nothing to
362
+ // delegate to would point the model at a call the gate refuses.
363
+ // `canFanOut(xcaps)` is defence in depth, not a live branch: `resolveCapabilities` empties
364
+ // `delegateTargets` whenever the delegate tool is absent (core/capabilities.ts), so the
365
+ // second conjunct already implies the first and no test can kill the first alone. Kept
366
+ // because it states the structural rule the second conjunct only happens to encode.
367
+ const canDelegate = xcaps ? canFanOut(xcaps) && h.agents.some((a) => canDelegateTo(xcaps, a.name)) : h.agents.length > 0;
368
+ const scope = exocom.scope;
369
+ const xbrief = buildExocomBrief((peers ?? []).map((p) => {
370
+ const workspaceId = p.workspace_id ?? scope?.scopeWorkspaceId;
371
+ const workspaceCode = p.workspace_code ?? (scope && workspaceId === scope.scopeWorkspaceId ? scope.scopeCode : undefined);
372
+ const workspaceLabel = p.workspace_label
373
+ ?? (scope && workspaceId === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : undefined);
374
+ return {
375
+ name: p.displayName,
376
+ persona: p.persona,
377
+ ...(workspaceCode && workspaceLabel ? {
378
+ workspaceCode,
379
+ workspaceLabel,
380
+ sameWorkspace: workspaceId === scope?.homeWorkspaceId,
381
+ } : {}),
382
+ };
383
+ }), {
384
+ canDelegate,
385
+ tools: {
386
+ name: !xcaps || canCallTool(xcaps, "exocom_name"),
387
+ list: !xcaps || canCallTool(xcaps, "exocom_list"),
388
+ send: !xcaps || canCallTool(xcaps, "exocom_send"),
389
+ claim: scope?.joined !== true && (!xcaps || canCallTool(xcaps, "exocom_claim")),
390
+ ask: !xcaps || canCallTool(xcaps, "exocom_ask"),
391
+ answer: !xcaps || canCallTool(xcaps, "exocom_answer"),
392
+ decline: !xcaps || canCallTool(xcaps, "exocom_decline"),
393
+ wait: !xcaps || canCallTool(xcaps, "exocom_wait"),
394
+ release: !xcaps || canCallTool(xcaps, "exocom_release"),
395
+ progress: !xcaps || canCallTool(xcaps, "exocom_progress"),
396
+ status: !xcaps || canCallTool(xcaps, "exocom_status"),
397
+ },
398
+ // Exocom has no UI gate, so a headless (`pi -p`) run has live peers and no way to ask
399
+ // anyone anything. `hasUI` is pi's dialog capability, not a headcount (see the field's
400
+ // doc) — but the clause it gates is an ask, and an ask needs a channel, not a person.
401
+ canAskHuman: ctx.hasUI === true,
402
+ namedByModel: exocom.namedByModel,
403
+ // Naming changes display metadata only. Pending work still gates every work action.
404
+ canNameNow: pi.getActiveTools().includes("exocom_name") && (!xcaps || canCallTool(xcaps, "exocom_name")),
405
+ ...(scope ? {
406
+ joined: scope.joined,
407
+ scopeCode: scope.scopeCode,
408
+ homeWorkspaceLabel: scope.homeWorkspaceLabel,
409
+ homeWorkspaceCode: scope.homeWorkspaceCode,
410
+ } : {}),
411
+ });
412
+ if (xbrief) prompt = `${prompt}\n\n${xbrief}`;
413
+ if (pendingBlock) prompt = `${prompt}\n\n${pendingBlock}`;
414
+ else if (ledgerFailure) prompt = `${prompt}\n\n${ledgerFailure}`;
415
+ }
416
+ if (h.pendingOrchestration) {
417
+ // The result is sub-agent text entering the SYSTEM prompt — fence it (I-guardrail:
418
+ // untrusted output must never reach the supervisor unfenced, least of all here).
419
+ if (h.pendingOrchestration.ok) {
420
+ // Keep the established success hand-off byte-for-byte: successful mandatory runs are
421
+ // still rulings the supervisor should present without spending the same council twice.
422
+ prompt = `${prompt}\n\n[orchestration: ${h.pendingOrchestration.label}] The mandated multi-agent orchestration was run on the user's request and produced the result below. Present and build on it as your answer — do not re-run it:\n\n${fenceUntrusted(h.pendingOrchestration.output)}`;
423
+ } else {
424
+ const evidence = h.pendingOrchestration.error && h.pendingOrchestration.error !== h.pendingOrchestration.output
425
+ ? `${h.pendingOrchestration.output}\n\n--- RUNTIME ERROR ---\n${h.pendingOrchestration.error}`
426
+ : h.pendingOrchestration.output;
427
+ prompt = h.pendingOrchestration.failureKind === "abort"
428
+ ? `${prompt}\n\n[orchestration: ${h.pendingOrchestration.label}] The mandated multi-agent orchestration was CANCELLED. Report the cancellation; do not present its partial output as approved, and do not resume or re-run it unless the user explicitly asks:\n\n${fenceUntrusted(evidence)}`
429
+ : `${prompt}\n\n[orchestration: ${h.pendingOrchestration.label}] The mandated multi-agent orchestration FAILED or remained unresolved. Do not present its output as approved or claim completion. Report the blocker, then repair and verify before declaring success:\n\n${fenceUntrusted(evidence)}`;
430
+ reportMandatoryFailure(ctx, h.pendingOrchestration);
431
+ }
432
+ h.pendingOrchestration = undefined;
433
+ }
434
+ const clock = readClockSnapshot(now);
435
+ return {
436
+ ...(prompt !== event.systemPrompt ? { systemPrompt: prompt } : {}),
437
+ ...(clock ? { message: { customType: "pi-persona-clock", content: clock, display: false } } : {}),
438
+ };
439
+ });
440
+
441
+ pi.on("tool_call", (event, ctx) => {
442
+ h.lastCtx = ctx;
443
+ const startedAt = Date.now();
444
+ h.telemetryToolStartedAt.set(event.toolCallId, { startedAt, name: event.toolName });
445
+ h.telemetry?.publish("tool.started", {
446
+ callId: event.toolCallId,
447
+ agentId: "supervisor",
448
+ name: event.toolName,
449
+ status: "running",
450
+ });
451
+ const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
452
+ const gate = h.controller.gate(event.toolName, event.input);
453
+ if (gate) {
454
+ h.telemetryToolStartedAt.delete(event.toolCallId);
455
+ h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
456
+ return gate;
457
+ }
458
+ if (exocom.ledgerFile) {
459
+ let pending: ReturnType<ExocomInstall["pendingAsks"]>;
460
+ try {
461
+ pending = exocom.pendingAsks(ctx);
462
+ } catch (error) {
463
+ if (constrainedTurnAllows(event.toolName)) pending = [];
464
+ else {
465
+ h.telemetryToolStartedAt.delete(event.toolCallId);
466
+ h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
467
+ return { block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` };
468
+ }
469
+ }
470
+ if (pending.length > 0 && !constrainedTurnAllows(event.toolName)) {
471
+ h.telemetryToolStartedAt.delete(event.toolCallId);
472
+ h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
473
+ const omitted = pending.length > 1 ? `; ${pending.length - 1} more pending` : "";
474
+ return {
475
+ block: true,
476
+ reason: `exocom: pending ask(s) to this session — only exocom_name (identity metadata), exocom_answer, exocom_decline, read, grep, find, ls until you answer (ask_id=${pending[0]!.ask_id}${omitted})`,
477
+ };
478
+ }
479
+ if (WRITE_TOOLS.has(event.toolName) && typeof input.path === "string" && input.path.trim()) {
480
+ // Advisory: a ledger read failure here must not block an otherwise-allowed write.
481
+ let claim: LedgerClaim | undefined;
482
+ try {
483
+ claim = peerClaimFor(input.path, exocom.ledgerState().claims, exocom.sessionId, ctx.cwd);
484
+ } catch {
485
+ claim = undefined;
486
+ }
487
+ if (claim) {
488
+ const key = writeWarningKey(claim, input.path);
489
+ if (!exocom.writeWarnings.seen(key)) {
490
+ exocom.writeWarnings.remember(key);
491
+ const label = exocom.peerLabelFor(claim.from_session) ?? claim.from_name;
492
+ const reason = writeWarningReason(input.path, claim, label);
493
+ try { if (ctx.hasUI) ctx.ui.notify(reason, "warning"); } catch { /* cosmetic */ }
494
+ h.telemetryToolStartedAt.delete(event.toolCallId);
495
+ h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
496
+ return { block: true, reason };
497
+ }
498
+ }
499
+ }
500
+ }
501
+ if (event.toolName === "intercom") {
502
+ const action = typeof input.action === "string" ? input.action : "message";
503
+ if (action === "send" || action === "reply") {
504
+ const text = typeof input.message === "string" ? input.message : "";
505
+ const askId = typeof input.askId === "string" ? input.askId : undefined;
506
+ h.telemetryIntercomPending.set(event.toolCallId, {
507
+ kind: action,
508
+ to: h.intercomRecipient(input, askId),
509
+ size: Buffer.byteLength(text, "utf8"),
510
+ ...(askId ? { replyTo: askId } : {}),
511
+ });
512
+ h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
513
+ id: event.toolCallId,
514
+ channel: "intercom",
515
+ from: "supervisor",
516
+ to: h.intercomRecipient(input, askId),
517
+ kind: action,
518
+ status: "queued",
519
+ expectsReply: false,
520
+ size: Buffer.byteLength(text, "utf8"),
521
+ ...(askId ? { replyTo: askId } : {}),
522
+ });
523
+ }
524
+ }
525
+ if (event.toolName === "exocom_send") {
526
+ const text = typeof input.message === "string" ? input.message : "";
527
+ const target = typeof input.target === "string" ? input.target : "";
528
+ let peers: DisplayPeer[] = [];
529
+ try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
530
+ const sessions = canonicalExocomTelemetryTargets(peers, target);
531
+ const size = Buffer.byteLength(text, "utf8");
532
+ const pending = sessions.map((sessionId) => ({
533
+ id: sessions.length === 1 ? event.toolCallId : `${event.toolCallId}:${sessionId}`,
534
+ sessionId,
535
+ target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
536
+ size,
537
+ }));
538
+ if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
539
+ for (const message of pending) h.telemetry?.publish("message.sent", {
540
+ id: message.id,
541
+ channel: "exocom",
542
+ from: exocom.telemetrySessionId(ctx),
543
+ to: message.sessionId,
544
+ kind: "message",
545
+ status: "queued",
546
+ expectsReply: false,
547
+ size: message.size,
548
+ });
549
+ }
550
+ return undefined;
551
+ });
552
+
553
+ // Delegation nudge (h.config.nudge; delegating h.personas only): when the supervisor grinds a RUN of
554
+ // hands-on commands by hand — a by-hand sweep with no hand-off — append a reminder to the
555
+ // offending tool's result. It lands in RECENT context, on the very command that tripped it, where
556
+ // a top-of-prompt persona directive has already lost its pull. Sub-h.agents run in their own
557
+ // sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council` result
558
+ // resets the run.
559
+ pi.on("tool_result", (event, ctx) => {
560
+ h.lastCtx = ctx;
561
+ const open = h.telemetryToolStartedAt.get(event.toolCallId);
562
+ h.telemetryToolStartedAt.delete(event.toolCallId);
563
+ if (open !== undefined) {
564
+ h.telemetry?.publish("tool.finished", {
565
+ callId: event.toolCallId,
566
+ agentId: "supervisor",
567
+ name: event.toolName,
568
+ status: event.isError ? "failed" : "done",
569
+ durationMs: Math.max(0, Date.now() - open.startedAt),
570
+ });
571
+ }
572
+ const telemetryInput = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
573
+ if (event.toolName === "exocom_send") {
574
+ const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
575
+ h.telemetryExocomPending.delete(event.toolCallId);
576
+ const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
577
+ ? event.details as { failed?: Array<{ target?: unknown }>; failedCount?: unknown; omittedFailures?: unknown }
578
+ : undefined;
579
+ const sampledFailures = details?.failed ?? [];
580
+ const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
581
+ // A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
582
+ // reports the rest as a count, so the sample alone CLEARS nobody: with more failures than
583
+ // the sample holds, a recipient we cannot find in it is unknown, never delivered.
584
+ const declaredFailures = typeof details?.failedCount === "number" ? details.failedCount : sampledFailures.length;
585
+ const omittedFailures = Math.max(
586
+ typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
587
+ declaredFailures - sampledFailures.length,
588
+ );
589
+ for (const message of pending) h.telemetry?.publish("message.sent", {
590
+ id: message.id,
591
+ channel: "exocom",
592
+ from: exocom.telemetrySessionId(ctx),
593
+ to: message.sessionId,
594
+ kind: "message",
595
+ status: event.isError || failedTargets.has(message.target) ? "failed" : omittedFailures > 0 ? "unknown" : "delivered",
596
+ expectsReply: false,
597
+ size: message.size,
598
+ });
599
+ }
600
+ if (event.toolName === "intercom") {
601
+ const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
602
+ // The open entry is the right to publish a terminal: once the turn-boundary drain has closed
603
+ // this call, a late result must not report it a second time.
604
+ const pending = h.telemetryIntercomPending.get(event.toolCallId);
605
+ h.telemetryIntercomPending.delete(event.toolCallId);
606
+ if (pending && (action === "send" || action === "reply")) {
607
+ const text = typeof telemetryInput.message === "string" ? telemetryInput.message : "";
608
+ const askId = typeof telemetryInput.askId === "string" ? telemetryInput.askId : undefined;
609
+ h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
610
+ id: event.toolCallId,
611
+ channel: "intercom",
612
+ from: "supervisor",
613
+ // Ask settlement has already retired its sender lookup. The tool-start
614
+ // snapshot owns the recipient for both queued and terminal telemetry.
615
+ to: pending.to,
616
+ kind: action,
617
+ status: event.isError ? "failed" : action === "reply" ? "replied" : "delivered",
618
+ expectsReply: false,
619
+ size: Buffer.byteLength(text, "utf8"),
620
+ ...(askId ? { replyTo: askId } : {}),
621
+ });
622
+ }
623
+ }
624
+ const errorPatch = piPersonaToolErrorPatch(event.details);
625
+ if (!h.config.nudge) return errorPatch;
626
+ // Only a supervisor that CAN delegate is nudged to — a persona without the tool can't act on it.
627
+ if (!h.controller.capabilities?.tools.has("delegate")) return errorPatch;
628
+ const notes: string[] = [];
629
+ // Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
630
+ // tools (delegate/council reset the run). `size` classifies substantive vs glue + fat dump.
631
+ const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
632
+ const size = text.length;
633
+ const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
634
+ if (sweepNote) notes.push(sweepNote);
635
+ // Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council
636
+ // results only; because delegate/council reset the run the two never fire on one event).
637
+ const surrender = h.persistenceNudge.observe(event.toolName, text);
638
+ if (surrender) notes.push(surrender);
639
+ if (notes.length === 0) return errorPatch;
640
+ const joined = notes.join("\n\n");
641
+ // Keep the semantic copy in the tool result for the model, and add a TUI-only durable card for
642
+ // the operator. Built-in/custom tool renderers are free to hide `content` when collapsed (some
643
+ // ignore it even when expanded), so the entry is the only cross-tool visibility guarantee.
644
+ h.appendNudgeEntry(joined);
645
+ return { ...errorPatch, content: [...event.content, { type: "text", text: joined }] };
646
+ });
647
+
648
+ // Mandatory orchestration: when the active persona declares a strategy/parallel/
649
+ // pipeline mode (or a flow), run it on the user's turn (the LLM cannot skip it) and
650
+ // fold the result into the prompt. Opportunistic h.personas (no orchestration) take the
651
+ // normal turn.
652
+ function mandatoryFailure(label: string, output: string, error: string, failureKind: FailureKind): MandatoryOutcome {
653
+ return { label, output, ok: false, error, failureKind };
654
+ }
655
+
656
+ /** Make a mandatory failure visible outside the hidden system prompt too. The cause may contain
657
+ * provider/model text, so compact it to one bounded operator-facing line. */
658
+ function reportMandatoryFailure(ctx: ExtensionContext, outcome: MandatoryOutcome): void {
659
+ const label = outcome.label.replace(/\s+/g, " ").trim().slice(0, 120);
660
+ const cause = (outcome.error ?? "unresolved result").replace(/\s+/g, " ").trim().slice(0, 500);
661
+ const kind = outcome.failureKind ? ` [${outcome.failureKind}]` : "";
662
+ const message = `pi-persona: mandatory orchestration ${label} failed${kind}: ${cause}`;
663
+ try {
664
+ if (ctx.hasUI) ctx.ui.notify(message, "error");
665
+ else process.stderr.write(`${message}\n`);
666
+ } catch {
667
+ // Diagnostics are best-effort; the fail-closed system-prompt hand-off remains authoritative.
668
+ }
669
+ }
670
+
671
+ async function executeMandatoryOrchestration(
672
+ ctx: ExtensionContext,
673
+ orch: OrchestrationGrammar,
674
+ task: string,
675
+ ): Promise<MandatoryOutcome> {
676
+ const flowName = orch.mode === "flow" ? orch.flow : undefined;
677
+ const label = flowName ? `flow ${flowName}` : (resolveStrategyName(orch) ?? "strategy");
678
+ try {
679
+ if (flowName) {
680
+ const parsed = h.loadFlow(ctx.cwd, flowName);
681
+ if (!parsed) {
682
+ const error = `no flow named "${flowName}"`;
683
+ return mandatoryFailure(label, error, error, "contract");
684
+ }
685
+ if (!parsed.ok) {
686
+ const error = `flow "${flowName}" is invalid: ${parsed.error}`;
687
+ return mandatoryFailure(label, error, error, "contract");
688
+ }
689
+ const outcome = await h.runFlowVisible(ctx, parsed.flow, task);
690
+ const output = outcome.output || "(the flow returned no output)";
691
+ if (outcome.ok) return { label, output, ok: true };
692
+ const error = outcome.cancelled
693
+ ? "the mandatory flow was aborted"
694
+ : (outcome.error ?? "the mandatory flow did not complete successfully");
695
+ const failureKind = outcome.cancelled ? "abort" : (outcome.failureKind ?? "agent");
696
+ return mandatoryFailure(label, output, error, failureKind);
697
+ }
698
+
699
+ const result = await h.runStrategyVisible(ctx, orch, task, "strategy");
700
+ if (!result) {
701
+ const error = "the mandatory orchestration returned no result";
702
+ return mandatoryFailure(label, `(${error})`, error, "contract");
703
+ }
704
+ if (!result.ok) {
705
+ return mandatoryFailure(
706
+ label,
707
+ result.output || "(the orchestration returned no output)",
708
+ result.error ?? "the mandatory orchestration did not complete successfully",
709
+ result.failureKind ?? "agent",
710
+ );
711
+ }
712
+ return { label, output: result.output, ok: true };
713
+ } catch (err) {
714
+ const error = `orchestration failed: ${err instanceof Error ? err.message : String(err)}`;
715
+ return mandatoryFailure(label, error, error, "agent");
716
+ }
717
+ }
718
+
719
+ function enqueueDeferredOrchestration(
720
+ task: string,
721
+ orchestration: OrchestrationGrammar,
722
+ ctx: ExtensionContext,
723
+ ): { action: "handled" } {
724
+ if (h.deferredOrchestrations.length >= h.MAX_DEFERRED_ORCHESTRATIONS) {
725
+ ctx.ui.notify(
726
+ `persona orchestration queue full (${h.MAX_DEFERRED_ORCHESTRATIONS}); input was not accepted — retry after the current run settles.`,
727
+ "error",
728
+ );
729
+ return { action: "handled" };
730
+ }
731
+ h.deferredOrchestrations.push({ task, orchestration });
732
+ ctx.ui.notify(`persona orchestration queued (${h.deferredOrchestrations.length}) — it will run after the current turn settles.`, "info");
733
+ return { action: "handled" };
734
+ }
735
+
736
+ async function processNextDeferredOrchestration(): Promise<boolean> {
737
+ if (h.disposed || h.processingDeferredOrchestration || h.deferredOrchestrations.length === 0 || !h.lastCtx) return false;
738
+ const item = h.deferredOrchestrations.shift()!;
739
+ h.processingDeferredOrchestration = true;
740
+ h.orchestrating = true;
741
+ try {
742
+ const result = await executeMandatoryOrchestration(h.lastCtx, item.orchestration, item.task);
743
+ if (h.disposed) return false;
744
+ h.pendingOrchestration = result;
745
+ // Source is "extension", so the input hook will not orchestrate this replay again.
746
+ sendPersonaFollowUp(pi, item.task, "pi-persona-deferred-input");
747
+ return true;
748
+ } catch (err) {
749
+ h.pendingOrchestration = undefined;
750
+ if (!h.disposed) {
751
+ h.deferredOrchestrations.unshift(item);
752
+ h.lastCtx.ui.notify(`could not replay deferred persona input: ${err instanceof Error ? err.message : String(err)}`, "error");
753
+ }
754
+ return false;
755
+ } finally {
756
+ h.orchestrating = false;
757
+ h.processingDeferredOrchestration = false;
758
+ }
759
+ }
760
+
761
+ pi.on("input", async (event, ctx) => {
762
+ h.lastCtx = ctx;
763
+ if (event.source === "extension") return undefined;
764
+ const orch = h.controller.activePersona?.orchestration;
765
+ const task = event.text?.trim();
766
+ if (!orch || !task) return undefined;
767
+ const flowName = orch.mode === "flow" ? orch.flow : undefined;
768
+ let strategyName: string | undefined;
769
+ try {
770
+ strategyName = resolveStrategyName(orch);
771
+ } catch (err) {
772
+ // Only the persona FILE can fix this; take the turn normally rather than tearing the
773
+ // input hook down on every keystroke, but say so loudly — the mandatory orchestration
774
+ // the user configured is silently not happening.
775
+ ctx.ui.notify(h.personaGrammarError(err), "error");
776
+ return undefined;
777
+ }
778
+ if (!flowName && !strategyName) return undefined;
779
+ const mainBusy = ctx.isIdle() !== true;
780
+ // Steering belongs to the turn already in progress. Never start a competing mandatory
781
+ // orchestration for it; Pi remains responsible for applying the steering message.
782
+ if (mainBusy && event.streamingBehavior !== "followUp") return { action: "continue" };
783
+ // Follow-up input must retain mandatory semantics without racing the live run. An input that
784
+ // arrives while another orchestration is executing joins the same FIFO even if Pi is idle.
785
+ if (mainBusy || h.orchestrating) return enqueueDeferredOrchestration(task, orch, ctx);
786
+ h.orchestrating = true;
787
+ try {
788
+ h.pendingOrchestration = await executeMandatoryOrchestration(ctx, orch, task);
789
+ } finally {
790
+ h.orchestrating = false;
791
+ }
792
+ // Let the user's original prompt proceed; the ruling is injected (hidden) into the
793
+ // turn's system prompt via before_agent_start — no internal plumbing in the chat.
794
+ return undefined;
795
+ });
796
+ }