@aeondave/pi-persona 1.17.3 → 1.17.5

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