@aeondave/pi-persona 1.17.3 → 1.17.4

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