@aeondave/pi-persona 1.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,970 @@
1
+ /**
2
+ * Exocom plane + work ledger + wait + tools. Owns session-scoped plane state.
3
+ */
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+
8
+ import { IdleCoalescingNotifier } from "../engine/async.ts";
9
+ import { attributePeer } from "../core/fence.ts";
10
+ import { exocomSelfStatusLabel, exocomSelfWidgetLabel } from "../core/naming.ts";
11
+ import type { PiPersonaConfig } from "../core/config.ts";
12
+ import { canCallTool, canParticipateInExocom, EXOCOM_TOOL_NAMES } from "../core/capabilities.ts";
13
+ import type { DelegationNudge } from "../core/nudge.ts";
14
+ import { shortModel } from "../tools/delegate.ts";
15
+ import { boundDisplayRows } from "../ui/presentation.ts";
16
+ import type { PersonaController } from "../persona/controller.ts";
17
+ import { type InstanceDescriptor, type PeerDescriptor } from "../telemetry/contract.ts";
18
+ import type { TelemetryProducer } from "../telemetry/producer.ts";
19
+ import {
20
+ boundExocomInboundBatch,
21
+ canDeliverPersonaNotification,
22
+ EXOCOM_INBOX_MAX,
23
+ exocomInboundBatchSize,
24
+ exocomInboundDisposition,
25
+ formatExocomQueuedBatchToast,
26
+ sanitizePeerField,
27
+ sendPersonaFollowUp,
28
+ shouldReportHeartbeatFailure,
29
+ type ExocomQueuedToast,
30
+ } from "../extension/shared.ts";
31
+ import { SeenMessages, SenderBudget } from "./guards.ts";
32
+ import { EXOCOM } from "./limits.ts";
33
+ import { endpoint as exocomEndpointFor, ledgerPath } from "./paths.ts";
34
+ import { ExocomPlane, type DisplayPeer } from "./plane.ts";
35
+ import type { ExocomArgSelection } from "./activation.ts";
36
+ import { selectExocomScope, type ExocomScope } from "./scope.ts";
37
+ import {
38
+ answerFor,
39
+ commitLedgerEvent,
40
+ droppedAskIds,
41
+ loadLedger,
42
+ pendingAskBlock,
43
+ pendingAsksTo,
44
+ pruneLedger,
45
+ type LedgerAnswer,
46
+ type LedgerAsk,
47
+ type LedgerPruneOptions,
48
+ type LedgerState,
49
+ } from "./ledger.ts";
50
+ import { buildInboundDelivery } from "./inbound.ts";
51
+ import { waitNoticeMatchesAskId } from "./wait.ts";
52
+ import type { ExocomSemanticFrame } from "./envelope.ts";
53
+ import { prune as pruneExocom, type RegistryEntry } from "./registry.ts";
54
+ import { registerExocomTools } from "../tools/exocom.ts";
55
+ import { registerExocomWorkTools } from "../tools/exocom-work.ts";
56
+ import type { SessionIdentity } from "../extension/identity.ts";
57
+ import { WriteWarnings } from "./write-guard.ts";
58
+
59
+ export interface ExocomHost {
60
+ readonly pi: ExtensionAPI;
61
+ readonly identity: SessionIdentity;
62
+ readonly config: PiPersonaConfig;
63
+ readonly controller: PersonaController;
64
+ get lastCtx(): ExtensionContext | undefined;
65
+ get disposed(): boolean;
66
+ get orchestrating(): boolean;
67
+ get processingDeferredOrchestration(): boolean;
68
+ get telemetry(): TelemetryProducer | undefined;
69
+ readonly delegationNudge: DelegationNudge;
70
+ readonly exocomArgs: ExocomArgSelection;
71
+ userAgentDir(): string;
72
+ }
73
+
74
+ export interface ExocomInstall {
75
+ refreshIdentity(): void;
76
+ reconcile(ctx: ExtensionContext): Promise<void>;
77
+ queue(op: () => Promise<void>): Promise<void>;
78
+ stop(): Promise<void>;
79
+ onPersonaChanged(ctx: ExtensionContext): Promise<void>;
80
+ syncActiveTools(): void;
81
+ telemetrySessionId(ctx: ExtensionContext): string;
82
+ currentTelemetryInstance(ctx: ExtensionContext): InstanceDescriptor;
83
+ publishTelemetryPeers(peers?: DisplayPeer[]): void;
84
+ get plane(): ExocomPlane | undefined;
85
+ get name(): string;
86
+ get namedByModel(): boolean;
87
+ get scope(): ExocomScope | undefined;
88
+ renderWidget(): void;
89
+ get ledgerFile(): string;
90
+ pendingAsks(ctx: ExtensionContext): LedgerAsk[];
91
+ pendingAskPrompt(ctx: ExtensionContext): string | undefined;
92
+ /** Current ledger snapshot (pruned + expired the same way every ledger read is). */
93
+ ledgerState(): LedgerState;
94
+ /** This session's own exocom session id, or "" when exocom is not active. */
95
+ get sessionId(): string;
96
+ /** A peer's display name for a session id, or undefined when it cannot be resolved. */
97
+ peerLabelFor(sessionId: string): string | undefined;
98
+ /** Warn-once memory for writes landing inside a peer's open claim (Task 7). Cleared on stop(). */
99
+ readonly writeWarnings: WriteWarnings;
100
+ get notifier(): IdleCoalescingNotifier<string> | undefined;
101
+ get waitNotifier(): IdleCoalescingNotifier<string> | undefined;
102
+ kickWait(): void;
103
+ }
104
+
105
+ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall {
106
+ // ── exocom (opt-in, T9): the EXTERNAL peer-to-peer plane ─────────────────────
107
+ // Independent top-level pi instances in this selected scope discover + message each other
108
+ // directly (flat) — distinct from the broker/intercom plane above, which is strictly
109
+ // hierarchical (a supervisor and its OWN spawned children). Off by default (host.config.exocom /
110
+ // --exocom), additionally gated by the active persona's canUseBus capability; no active
111
+ // persona ⇒ unrestricted (mirrors delegationBrief's own reading of an absent capability set,
112
+ // rather than silently refusing to join for an unpersona'd session — see startExocom below).
113
+ let exocomPlane: ExocomPlane | undefined;
114
+ let exocomLedgerFile = "";
115
+ let exocomSessionId = "";
116
+ let refreshIdentity: (() => void) | undefined;
117
+ let exocomScope: ExocomScope | undefined;
118
+ interface ExocomWaiter { id: string; work_key: string; ask_id: string; handle: ReturnType<typeof setTimeout>; }
119
+ const exocomWaiters: ExocomWaiter[] = [];
120
+ let exocomWaitSeq = 0;
121
+ let exocomWaitNotifier: IdleCoalescingNotifier<string> | undefined;
122
+ let exocomBudget: SenderBudget | undefined;
123
+ let exocomSeen: SeenMessages | undefined;
124
+ let exocomNotifier: IdleCoalescingNotifier<string> | undefined;
125
+ /** Latched while the inbox is at EXOCOM_INBOX_MAX, so a peer hammering a full inbox produces one
126
+ * operator warning rather than one per refused message. */
127
+ let exocomInboxFull = false;
128
+ let exocomToastNotifier: IdleCoalescingNotifier<ExocomQueuedToast> | undefined;
129
+ let exocomHeartbeat: ReturnType<typeof setInterval> | undefined;
130
+ let exocomHeartbeatFailures = 0; // consecutive failed ticks — drives the report cadence, reset by any success
131
+ let exocomResetTimer: ReturnType<typeof setInterval> | undefined;
132
+ // Lives for the whole install (not just one plane instance), so it survives a stop/start
133
+ // cycle's OWN teardown — but stopExocom() still clears it, since a fresh plane means fresh
134
+ // claims and a stale "already warned" memory would silently hide a real overlap.
135
+ const writeWarnings = new WriteWarnings();
136
+
137
+ function exocomRequested(): boolean {
138
+ return host.config.exocom
139
+ || pi.getFlag("exocom") === true
140
+ || host.exocomArgs.joinCode !== undefined
141
+ || host.exocomArgs.error !== undefined;
142
+ }
143
+
144
+ function peerWorkspace(peer: DisplayPeer): { label: string; code: string; same: boolean } | undefined {
145
+ const scope = exocomScope;
146
+ if (!scope) return undefined;
147
+ const id = peer.workspace_id ?? scope.scopeWorkspaceId;
148
+ const code = peer.workspace_code ?? (id === scope.scopeWorkspaceId ? scope.scopeCode : "");
149
+ if (!code) return undefined;
150
+ return {
151
+ label: peer.workspace_label ?? (id === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : "workspace"),
152
+ code,
153
+ same: id === scope.homeWorkspaceId,
154
+ };
155
+ }
156
+
157
+ function currentLedgerPruneOptions(): LedgerPruneOptions | undefined {
158
+ const plane = exocomPlane;
159
+ if (!plane || !exocomSessionId) return undefined;
160
+ try {
161
+ const liveSessions = new Set([exocomSessionId, ...plane.listPeers().map((peer) => peer.session_id)]);
162
+ return {
163
+ now: Date.now(),
164
+ ttlMs: EXOCOM.LEDGER_RESULT_TTL_MS,
165
+ liveSessions,
166
+ };
167
+ } catch {
168
+ // Registry uncertainty must not erase ownership in memory. Keep the durable state until
169
+ // liveness can be established again; the hooks will still fail closed on ledger I/O itself.
170
+ return undefined;
171
+ }
172
+ }
173
+
174
+ // Declared with `function` (not a const arrow) so hoisting makes it callable from
175
+ // `currentLedgerState` above regardless of definition order — it only touches `exocomWaiters`/
176
+ // `exocomWaitNotifier`, both already in scope at the top of `installExocom`.
177
+ /** Wake every waiter whose ask was pruned because a party left the pool — the next heartbeat,
178
+ * not the ten-minute cap, is when the supervisor learns the answer will never come. */
179
+ function expireWaiters(askIds: string[]): void {
180
+ if (askIds.length === 0 || exocomWaiters.length === 0) return;
181
+ const gone = new Set(askIds);
182
+ for (let i = exocomWaiters.length - 1; i >= 0; i--) {
183
+ const waiter = exocomWaiters[i]!;
184
+ if (!gone.has(waiter.ask_id)) continue;
185
+ clearTimeout(waiter.handle);
186
+ exocomWaiters.splice(i, 1);
187
+ exocomWaitNotifier?.notify(`[pi-persona] exocom wait ended: the peer left the pool before answering · work_key=${waiter.work_key} ask_id=${waiter.ask_id}`);
188
+ }
189
+ }
190
+
191
+ function currentLedgerState(): LedgerState {
192
+ if (!exocomLedgerFile) throw new Error("exocom ledger is not available");
193
+ const state = loadLedger(exocomLedgerFile);
194
+ const prune = currentLedgerPruneOptions();
195
+ if (!prune) return state;
196
+ const pruned = pruneLedger(state, prune);
197
+ expireWaiters(droppedAskIds(state, pruned));
198
+ return pruned;
199
+ }
200
+
201
+ function pendingAsksFor(ctx: ExtensionContext): LedgerAsk[] {
202
+ if (!exocomLedgerFile) return [];
203
+ return pendingAsksTo(currentLedgerState(), exocomSessionId || ctx.sessionManager.getSessionId());
204
+ }
205
+
206
+ /** A peer's display name for a session id, or undefined when the registry can't resolve it.
207
+ * Session ids are validated tokens and remain safe fallback attribution on their own. */
208
+ function peerLabelFor(sessionId: string): string | undefined {
209
+ try {
210
+ return exocomPlane?.listPeers().find((peer) => peer.session_id === sessionId)?.displayName;
211
+ } catch {
212
+ return undefined;
213
+ }
214
+ }
215
+
216
+ function pendingAskPromptFor(ctx: ExtensionContext): string | undefined {
217
+ const asks = pendingAsksFor(ctx);
218
+ return pendingAskBlock(asks, peerLabelFor);
219
+ }
220
+
221
+ function renderLedgerAnswer(answer: LedgerAnswer, label = answer.from_session): string {
222
+ return [
223
+ `[pi-persona] exocom answer · work_key=${answer.work_key} ask_id=${answer.ask_id} ok=${answer.ok}`,
224
+ attributePeer(label, answer.evidence),
225
+ ].join("\n");
226
+ }
227
+
228
+ // A tiny, stable name→color hash for the pool widget's swatch — exocom peers carry no
229
+ // persona "label colour" of their own to read, so this derives one deterministically
230
+ // instead of hardcoding one default for every peer.
231
+ const EXOCOM_PALETTE = ["#36F9F6", "#FF6B6B", "#FFD93D", "#6BCB77", "#4D96FF", "#C780FA", "#FF9F1C", "#00C2A8"];
232
+ function exocomColorFor(name: string): string {
233
+ let h = 0;
234
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
235
+ return EXOCOM_PALETTE[h % EXOCOM_PALETTE.length]!;
236
+ }
237
+
238
+ function telemetrySessionId(ctx: ExtensionContext): string {
239
+ return (ctx as ExtensionContext & { sessionManager?: { getSessionId?: () => string } }).sessionManager?.getSessionId?.() ?? `legacy-${process.pid}`;
240
+ }
241
+
242
+ function currentTelemetryInstance(ctx: ExtensionContext): InstanceDescriptor {
243
+ const runtimeCtx = ctx as ExtensionContext & {
244
+ sessionManager?: { getSessionId?: () => string };
245
+ isIdle?: () => boolean;
246
+ getContextUsage?: () => { percent?: number } | undefined;
247
+ };
248
+ const personaName = host.controller.activePersona?.name ?? "";
249
+ const displayName = exocomSelfStatusLabel(host.identity.chosen, host.identity.name, personaName);
250
+ return {
251
+ displayName,
252
+ persona: host.controller.activePersona?.name ?? "",
253
+ model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
254
+ status: runtimeCtx.isIdle?.() === true ? "idle" : "active",
255
+ pid: process.pid,
256
+ // Clamped HERE, not merely in the producer's `sanitizeInstance`: that one runs only for
257
+ // `instance.started`, so an out-of-range reading would ride `instance.updated` and
258
+ // `instance.heartbeat` out to consumers, where the contract rejects the WHOLE event over
259
+ // the one bad field.
260
+ contextPercent: Math.max(0, Math.min(100, Math.round(runtimeCtx.getContextUsage?.()?.percent ?? 0))),
261
+ exocomEnabled: exocomPlane !== undefined,
262
+ color: exocomColorFor(displayName),
263
+ };
264
+ }
265
+
266
+ function publishTelemetryPeers(peers?: DisplayPeer[]): void {
267
+ const producer = host.telemetry;
268
+ if (!producer) return;
269
+ let pool = peers;
270
+ if (!pool) {
271
+ try { pool = exocomPlane?.listPeers() ?? []; } catch { pool = []; }
272
+ }
273
+ const now = Date.now();
274
+ const payload: PeerDescriptor[] = pool.map((peer) => ({
275
+ sessionId: peer.session_id,
276
+ displayName: peer.displayName,
277
+ persona: peer.persona,
278
+ model: peer.model,
279
+ contextPercent: peer.context_pct,
280
+ status: now - Date.parse(peer.heartbeat_at) > EXOCOM.QUIET_AFTER_MS ? "idle" : "online",
281
+ ...(peer.color ? { color: peer.color } : {}),
282
+ sent: exocomPlane?.sentToPeer(peer.session_id) ?? 0,
283
+ received: exocomPlane?.receivedFromPeer(peer.session_id) ?? 0,
284
+ }));
285
+ producer.publish("peers.snapshot", { peers: payload });
286
+ }
287
+
288
+ // One row per live peer (the registry read itself IS the pool — each peer refreshes its
289
+ // own entry on its own heartbeat, so no ping fan-out is needed here). Best-effort/cosmetic,
290
+ // mirrors renderAgentWidget above.
291
+ function renderExocomWidget(): void {
292
+ if (!host.lastCtx || !exocomPlane) return;
293
+ // The pool read is disk I/O (readdir + a read per entry) and can fail like any other, so it
294
+ // is guarded too — otherwise "best-effort" would be false of the very first statement, and
295
+ // a registry hiccup would escape into whatever tick asked for a repaint. There is nothing
296
+ // to draw without it; the next heartbeat repaints.
297
+ let peers: DisplayPeer[];
298
+ try {
299
+ peers = exocomPlane.listPeers();
300
+ } catch {
301
+ return;
302
+ }
303
+ publishTelemetryPeers(peers);
304
+ try {
305
+ const now = Date.now();
306
+ const selfPersona = sanitizePeerField(host.controller.activePersona?.name ?? "", 48);
307
+ const selfModel = sanitizePeerField(host.lastCtx.model ? `${host.lastCtx.model.provider}/${host.lastCtx.model.id}` : "", 96);
308
+ const selfContextPct = Math.max(0, Math.min(100, Math.round(host.lastCtx.getContextUsage()?.percent ?? 0)));
309
+ const ident = exocomSelfWidgetLabel(host.identity.chosen, host.identity.name, selfPersona);
310
+ const roleBit = host.identity.chosen && selfPersona ? ` · ${selfPersona}` : "";
311
+ const scopeBit = exocomScope
312
+ ? exocomScope.joined
313
+ ? ` · home ${exocomScope.homeWorkspaceLabel} [${exocomScope.homeWorkspaceCode}] → scope [${exocomScope.scopeCode}]`
314
+ : ` · workspace ${exocomScope.homeWorkspaceLabel} [${exocomScope.scopeCode}]`
315
+ : "";
316
+ const local = `📡 ${ident}${roleBit} · ${shortModel(selfModel) || "?"} · ctx ${selfContextPct}%${scopeBit}`;
317
+ const peerRows = peers.map((p) => {
318
+ const quiet = now - Date.parse(p.heartbeat_at) > EXOCOM.QUIET_AFTER_MS;
319
+ const name = sanitizePeerField(p.displayName, 48) || "peer";
320
+ const persona = sanitizePeerField(p.persona, 48);
321
+ const model = sanitizePeerField(p.model, 96);
322
+ const contextPct = Number.isFinite(p.context_pct) ? Math.max(0, Math.min(100, Math.round(p.context_pct))) : 0;
323
+ // Viewer-centric: THIS row's in/out is what WE exchanged with THIS peer, not
324
+ // the peer's own global self-report (which reads inverted from our side).
325
+ const workspace = peerWorkspace(p);
326
+ const workspaceBit = workspace ? ` · ${workspace.same ? "ws" : "external"} ${workspace.label} [${workspace.code}]` : "";
327
+ return `${quiet ? "💤" : "📡"} ${name}${persona ? ` (${persona})` : ""} · ${shortModel(model) || "?"} · ctx ${contextPct}% · recv ${exocomPlane?.receivedFromPeer(p.session_id) ?? 0} · sent ${exocomPlane?.sentToPeer(p.session_id) ?? 0}${workspaceBit}`;
328
+ });
329
+ const lines = boundDisplayRows(local, peerRows, 7, "exocom_list for the full pool");
330
+ host.lastCtx.ui.setWidget("persona-exocom", lines, { placement: "aboveEditor" });
331
+ } catch {
332
+ /* cosmetic — the widget is best-effort */
333
+ }
334
+ try {
335
+ host.lastCtx.ui.setStatus(
336
+ "persona-exocom",
337
+ `📡 ${exocomSelfStatusLabel(host.identity.chosen, host.identity.name, host.controller.activePersona?.name ?? "")} · ${exocomScope?.joined ? `scope ${exocomScope.scopeCode} · home ${exocomScope.homeWorkspaceCode}` : `workspace ${exocomScope?.scopeCode ?? "?"}`} · ${peers.length} peer${peers.length === 1 ? "" : "s"} · ${exocomPlane?.totalReceived ?? 0} in · ${exocomPlane?.totalSent ?? 0} out`,
338
+ );
339
+ } catch {
340
+ /* cosmetic */
341
+ }
342
+ }
343
+
344
+ // Re-register with the CURRENT persona/model/context% (so a `/persona` switch or a model change
345
+ // is reflected, not a stale snapshot from session_start) and prune dead peers — one unref'd tick
346
+ // covers both heartbeat AND pool refresh. Routed through `plane.heartbeat` rather than a bare
347
+ // `writeEntry`: the plane re-attaches its ed25519 public key on every re-registration, including
348
+ // one that RE-CREATES an entry a peer deleted underneath us (plane.ts) — the key lives nowhere
349
+ // but this process, and an entry without it makes every frame we sign unverifiable.
350
+ function exocomHeartbeatTick(agentDir: string, hash: string, sessionId: string, ep: string, cwd: string): void {
351
+ const plane = exocomPlane;
352
+ const scope = exocomScope;
353
+ if (!plane || !scope) return;
354
+ const persona = sanitizePeerField(host.controller.activePersona?.name ?? "", 48);
355
+ const model = sanitizePeerField(host.lastCtx?.model ? `${host.lastCtx.model.provider}/${host.lastCtx.model.id}` : "", 96);
356
+ const entry: RegistryEntry = {
357
+ session_id: sessionId,
358
+ name: host.identity.name,
359
+ persona,
360
+ purpose: host.controller.activePersona?.description ?? "",
361
+ color: exocomColorFor(host.identity.name),
362
+ model,
363
+ pid: process.pid,
364
+ endpoint: ep,
365
+ cwd,
366
+ context_pct: Math.round(host.lastCtx?.getContextUsage()?.percent ?? 0),
367
+ inbox: exocomNotifier?.peekPending().length ?? 0,
368
+ heartbeat_at: new Date().toISOString(),
369
+ workspace_id: scope.homeWorkspaceId,
370
+ workspace_code: scope.homeWorkspaceCode,
371
+ workspace_label: scope.homeWorkspaceLabel,
372
+ };
373
+ plane.heartbeat(entry);
374
+ // Everything past the re-registration is local upkeep: sweeping OTHER instances' dead entries
375
+ // and repainting our own widget. Letting a failure there out would be counted as a heartbeat
376
+ // failure and tell the user "peers drop this instance from the pool" — which would be false,
377
+ // since the write above is exactly what keeps us in it. Only the registration decides that.
378
+ try {
379
+ pruneExocom(agentDir, hash, { now: Date.now(), staleMs: EXOCOM.STALE_AFTER_MS });
380
+ if (exocomWaiters.length > 0) currentLedgerState(); // a waiter's peer may have just been pruned
381
+ renderExocomWidget();
382
+ } catch (err) {
383
+ if (process.env.PI_PERSONA_DEBUG) {
384
+ process.stderr.write(`[pi-persona] exocom: pool upkeep failed after a good heartbeat: ${err instanceof Error ? err.message : String(err)}\n`);
385
+ }
386
+ }
387
+ }
388
+
389
+ // Join the plane for this session — called from session_start ONCE the persona is applied,
390
+ // so identity + the canUseBus gate reflect the persona actually active. Never throws: a
391
+ // bind/registry failure degrades to "exocom inactive", it must never block a normal session
392
+ // from starting (mirrors the broker host's own fire-and-forget-on-failure discipline).
393
+ async function startExocom(ctx: ExtensionContext): Promise<void> {
394
+ if (!exocomRequested()) return;
395
+ if (!canParticipateInExocom(host.controller.capabilities)) return;
396
+ try {
397
+ if (host.exocomArgs.error) throw new Error(host.exocomArgs.error);
398
+ const sessionId = ctx.sessionManager.getSessionId();
399
+ exocomSessionId = sessionId;
400
+ const agentDir = host.userAgentDir();
401
+ const scope = exocomScope ?? selectExocomScope(agentDir, ctx.cwd, host.exocomArgs.joinCode);
402
+ exocomScope = scope;
403
+ const hash = scope.scopeWorkspaceId;
404
+ // Identity belongs to the session, including when Exocom is disabled or restarted.
405
+ // Until a name is chosen, the unique provisional label stays distinct from its persona.
406
+ pruneExocom(agentDir, hash, { now: Date.now(), staleMs: EXOCOM.STALE_AFTER_MS });
407
+ const ep = exocomEndpointFor(agentDir, hash, sessionId, process.platform);
408
+ const persona = sanitizePeerField(host.controller.activePersona?.name ?? "", 48);
409
+ const model = sanitizePeerField(ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "", 96);
410
+ exocomBudget = new SenderBudget({ windowMs: EXOCOM.SENDER_WINDOW_MS, maxMsgs: EXOCOM.SENDER_MAX_MSGS, maxBytes: EXOCOM.SENDER_MAX_BYTES });
411
+ exocomSeen = new SeenMessages({ ttlMs: EXOCOM.SEEN_TTL_MS });
412
+ exocomLedgerFile = ledgerPath(agentDir, hash);
413
+ exocomWaitNotifier = new IdleCoalescingNotifier<string>({
414
+ isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle?.() === true),
415
+ deliver: (message) => sendPersonaFollowUp(pi, message, "pi-persona"),
416
+ render: (items) => items.join("\n\n"),
417
+ setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
418
+ clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
419
+ });
420
+ // Fenced, attributed follow-up delivery — idle-gated + rate-limited (R6), the same
421
+ // discipline completionNotifier/intercomNotifier/timerNotifier apply above, but through
422
+ // pi.sendMessage (a distinct, labellable custom message) rather than pi.sendUserMessage
423
+ // (mirrors the bridge's own `sendFollowUp` for inbound cross-process text).
424
+ exocomNotifier = new IdleCoalescingNotifier<string>({
425
+ isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle?.() === true),
426
+ deliver: (message) => sendPersonaFollowUp(pi, message, "exocom_received"),
427
+ // A burst of independent peers must not turn one idle wake into an unbounded model
428
+ // payload. Each item is already attributed and peer-fenced by buildInboundDelivery;
429
+ // boundCompletionSurface keeps complete lines (including quote prefixes) intact while
430
+ // retaining a bounded head/tail sample and an actionable drill-down marker.
431
+ render: boundExocomInboundBatch,
432
+ // Deliver whole peer messages — as many as one wake renders untruncated — and retain the
433
+ // rest in FIFO order; truncating a coalesced burst would silently lose communication that
434
+ // has no other retrieval path. Sized by content, so the drain keeps pace with ordinary
435
+ // traffic instead of being pinned to one message per wake (see exocomInboundBatchSize).
436
+ maxBatchItems: exocomInboundBatchSize,
437
+ setTimer: (fn, ms) => {
438
+ const h = setTimeout(fn, ms);
439
+ h.unref?.();
440
+ return h;
441
+ },
442
+ clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
443
+ minIntervalMs: EXOCOM.DELIVER_MIN_INTERVAL_MS,
444
+ maxDeliveries: EXOCOM.DELIVER_MAX_PER_MIN,
445
+ });
446
+ exocomToastNotifier = new IdleCoalescingNotifier<ExocomQueuedToast>({
447
+ // This channel is human-only and deliberately independent from model idleness: it folds a
448
+ // burst of arrivals into one small receipt instead of emitting one toast per peer message.
449
+ isIdle: () => true,
450
+ deliver: (message) => {
451
+ try {
452
+ host.lastCtx?.ui.notify(message, "info");
453
+ } catch {
454
+ /* cosmetic */
455
+ }
456
+ },
457
+ render: formatExocomQueuedBatchToast,
458
+ setTimer: (fn, ms) => {
459
+ const h = setTimeout(fn, ms);
460
+ h.unref?.();
461
+ return h;
462
+ },
463
+ clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
464
+ debounceMs: 200,
465
+ });
466
+ const fireWaiters = (answer: LedgerAnswer, label?: string): boolean => {
467
+ let fired = false;
468
+ for (let i = exocomWaiters.length - 1; i >= 0; i--) {
469
+ const waiter = exocomWaiters[i]!;
470
+ if (waiter.work_key !== answer.work_key || waiter.ask_id !== answer.ask_id) continue;
471
+ clearTimeout(waiter.handle);
472
+ exocomWaiters.splice(i, 1);
473
+ exocomWaitNotifier?.notify(renderLedgerAnswer(answer, label));
474
+ fired = true;
475
+ }
476
+ return fired;
477
+ };
478
+ const armExocomWait = (work_key: string, ask_id: string, timeoutMs: number) => {
479
+ // One snapshot supplies both branches. If an answer commits after this read, the
480
+ // post-registration read below observes it. Taking a second pre-registration
481
+ // snapshot for the pending ask would instead create a gap where the answer removes
482
+ // the ask and we falsely report "no pending outbound ask".
483
+ const initial = currentLedgerState();
484
+ const existing = answerFor(initial, ask_id, sessionId);
485
+ if (existing) {
486
+ if (existing.work_key !== work_key) throw new Error("exocom_wait: work_key does not match the retained answer");
487
+ exocomWaitNotifier?.discard((item) => waitNoticeMatchesAskId(item, ask_id));
488
+ exocomNotifier?.discard((item) => waitNoticeMatchesAskId(item, ask_id));
489
+ return { status: "answered" as const, answer: existing };
490
+ }
491
+ const pending = initial.asks.find((ask) => ask.ask_id === ask_id && ask.from_session === sessionId);
492
+ if (!pending) throw new Error(`exocom_wait: no pending outbound ask "${ask_id}"`);
493
+ if (pending.work_key !== work_key) throw new Error("exocom_wait: work_key does not match the pending ask");
494
+ const id = `exocom-wait-${++exocomWaitSeq}`;
495
+ const handle = setTimeout(() => {
496
+ const idx = exocomWaiters.findIndex((w) => w.id === id);
497
+ if (idx >= 0) exocomWaiters.splice(idx, 1);
498
+ try {
499
+ const landed = answerFor(currentLedgerState(), ask_id, sessionId);
500
+ exocomWaitNotifier?.notify(landed
501
+ ? renderLedgerAnswer(landed)
502
+ : `[pi-persona] exocom wait timed out after ${timeoutMs}ms · work_key=${work_key} ask_id=${ask_id}`);
503
+ } catch (error) {
504
+ exocomWaitNotifier?.notify(`[pi-persona] exocom wait could not read the ledger · ask_id=${ask_id} · ${error instanceof Error ? error.message : String(error)}`);
505
+ }
506
+ }, timeoutMs);
507
+ handle.unref?.();
508
+ const waiter: ExocomWaiter = { id, work_key, ask_id, handle };
509
+ exocomWaiters.push(waiter);
510
+ const disarm = (): void => {
511
+ clearTimeout(handle);
512
+ const idx = exocomWaiters.indexOf(waiter);
513
+ if (idx >= 0) exocomWaiters.splice(idx, 1);
514
+ };
515
+ // Close the small cross-process window between the first read and waiter registration.
516
+ let raced: LedgerAnswer | undefined;
517
+ try {
518
+ raced = answerFor(currentLedgerState(), ask_id, sessionId);
519
+ } catch (error) {
520
+ // The tool call is failing, so it must not leave a hidden waiter that later starts
521
+ // an unrelated Pi turn with a timeout/read-error follow-up.
522
+ disarm();
523
+ throw error;
524
+ }
525
+ if (raced) {
526
+ disarm();
527
+ exocomWaitNotifier?.discard((item) => waitNoticeMatchesAskId(item, ask_id));
528
+ exocomNotifier?.discard((item) => waitNoticeMatchesAskId(item, ask_id));
529
+ return { status: "answered" as const, answer: raced };
530
+ }
531
+ return { status: "waiting" as const, id };
532
+ };
533
+ const dispatchSemantic = async (frame: ExocomSemanticFrame): Promise<{ msg_id: string; peerWakeDeferred?: true }> => {
534
+ const plane = exocomPlane;
535
+ if (!plane) throw new Error("exocom is not active for this persona");
536
+ if (!exocomLedgerFile) throw new Error("exocom ledger is not available");
537
+ let target: DisplayPeer | undefined;
538
+ if (frame.kind === "ask") {
539
+ target = plane.listPeers().find((peer) => peer.session_id === frame.to_session);
540
+ if (!target) throw new Error(`exocom: unknown peer session "${frame.to_session}"`);
541
+ } else if (frame.kind === "answer") {
542
+ const ask = currentLedgerState().asks.find((candidate) => candidate.ask_id === frame.ask_id);
543
+ if (!ask) throw new Error(`exocom: answer for unknown ask_id "${frame.ask_id}"`);
544
+ try {
545
+ target = plane.listPeers().find((peer) => peer.session_id === ask.from_session);
546
+ } catch {
547
+ // The answer is a ledger transition first. Registry uncertainty may defer the
548
+ // immediate wake, but must not keep the receiver trapped behind the ask gate.
549
+ }
550
+ }
551
+ const prune = currentLedgerPruneOptions();
552
+ const applied = commitLedgerEvent(exocomLedgerFile, frame, prune ? { prune } : {});
553
+ if (!applied.ok) throw new Error(`exocom: ${applied.error}`);
554
+ if (applied.duplicate === true) return { msg_id: frame.msg_id };
555
+ // Claims/progress/releases are authoritative in the shared ledger and need no postcard.
556
+ // Ask/answer use the wire only as a signed immediate-wake signal; durable state remains
557
+ // recoverable if that signal fails.
558
+ if (frame.kind === "answer" && !target) {
559
+ try {
560
+ host.lastCtx?.ui.notify("exocom: ledger updated, but answer wake was deferred.", "warning");
561
+ } catch { /* the durable result is still recoverable from the ledger */ }
562
+ return { msg_id: frame.msg_id, peerWakeDeferred: true };
563
+ }
564
+ if (target && (frame.kind === "ask" || frame.kind === "answer")) {
565
+ try {
566
+ await plane.sendSemantic(target.target, frame);
567
+ } catch {
568
+ try {
569
+ host.lastCtx?.ui.notify(
570
+ `exocom: ledger updated, but ${frame.kind} wake to ${target.displayName} was deferred.`,
571
+ "warning",
572
+ );
573
+ } catch { /* the durable result is still recoverable from the ledger */ }
574
+ return { msg_id: frame.msg_id, peerWakeDeferred: true };
575
+ }
576
+ }
577
+ return { msg_id: frame.msg_id };
578
+ };
579
+
580
+ exocomPlane = new ExocomPlane({
581
+
582
+ agentDir,
583
+ hash,
584
+ identity: {
585
+ session_id: sessionId,
586
+ get name() { return host.identity.name; },
587
+ persona,
588
+ purpose: host.controller.activePersona?.description ?? "",
589
+ color: exocomColorFor(host.identity.name),
590
+ model,
591
+ endpoint: ep,
592
+ cwd: ctx.cwd,
593
+ workspace: {
594
+ id: scope.homeWorkspaceId,
595
+ code: scope.homeWorkspaceCode,
596
+ label: scope.homeWorkspaceLabel,
597
+ },
598
+ },
599
+ getCard: () => ({
600
+ name: host.identity.name,
601
+ persona: sanitizePeerField(host.controller.activePersona?.name ?? "", 48),
602
+ model: sanitizePeerField(host.lastCtx?.model ? `${host.lastCtx.model.provider}/${host.lastCtx.model.id}` : "", 96),
603
+ context_pct: Math.round(host.lastCtx?.getContextUsage()?.percent ?? 0),
604
+ inbox: exocomNotifier?.peekPending().length ?? 0,
605
+ }),
606
+ onInbound: (msg, fromEntry) => {
607
+ const queued = exocomNotifier?.peekPending().length ?? 0;
608
+ if (queued >= EXOCOM_INBOX_MAX) {
609
+ if (!exocomInboxFull) {
610
+ exocomInboxFull = true;
611
+ try {
612
+ host.lastCtx?.ui.notify(`exocom: inbox full (${queued} undelivered peer messages) — refusing further messages until it drains.`, "warning");
613
+ } catch {
614
+ /* cosmetic */
615
+ }
616
+ }
617
+ return { accepted: false, reason: `receiver inbox full (${queued} queued) — retry once it drains` };
618
+ }
619
+ exocomInboxFull = false;
620
+ // Attribution from the REGISTRY entry keyed by the connecting session — never from
621
+ // msg.from_name (the envelope's own self-report, not to be trusted; see inbound.ts).
622
+ // Sanitized (I2): fromEntry.name/persona are PEER-WRITTEN registry fields, and
623
+ // The inbound builder places this label OUTSIDE the quote — a CR/LF-laden name must
624
+ // not be able to inject pseudo-instructions there.
625
+ const peerName = sanitizePeerField(
626
+ fromEntry ? (exocomPlane?.humanDisplayLabelFor(fromEntry) ?? fromEntry.name) : msg.from_session,
627
+ 48,
628
+ ) || "peer";
629
+ const peerPersona = sanitizePeerField(fromEntry?.persona ?? "", 48);
630
+ const label = peerPersona ? `${peerName} (${peerPersona})` : peerName;
631
+ // The reply hint is a stable qualified token from the authenticated registry entry,
632
+ // not the human display name. Raw names are shared by live twins and can become stale
633
+ // between delivery and the model's reply; the plane caches this token with the sender's
634
+ // endpoint/key so it remains routable even after registry pruning. Attribution remains
635
+ // the separate sanitized human label above.
636
+ const replyTarget = fromEntry ? exocomPlane?.replyTargetFor(fromEntry) : undefined;
637
+ const decision = buildInboundDelivery(msg, label, {
638
+ budget: exocomBudget!,
639
+ seen: exocomSeen!,
640
+ injectMaxBytes: EXOCOM.INJECT_MAX_BYTES,
641
+ ...(replyTarget ? { replyTarget } : {}),
642
+ });
643
+ const disposition = exocomInboundDisposition(decision);
644
+ host.telemetry?.publish("message.received", {
645
+ id: msg.msg_id,
646
+ channel: "exocom",
647
+ from: fromEntry?.session_id ?? msg.from_session,
648
+ to: sessionId,
649
+ kind: "message",
650
+ status: disposition.accepted ? "delivered" : "rejected",
651
+ expectsReply: false,
652
+ size: Buffer.byteLength(msg.text, "utf8"),
653
+ ...(msg.in_reply_to ? { replyTo: msg.in_reply_to } : {}),
654
+ });
655
+ if ("deliver" in decision) {
656
+ exocomNotifier?.notify(decision.deliver);
657
+ // Never inject peer text into a live model turn. A compact human-only toast makes
658
+ // receipt visible while a long tool loop keeps the follow-up idle-gated.
659
+ if (host.lastCtx?.isIdle?.() !== true) {
660
+ exocomToastNotifier?.notify({ label, inReplyTo: msg.in_reply_to });
661
+ }
662
+ }
663
+ // Plane accounting happens after this callback returns. Defer the render one
664
+ // microtask so the inbound counter shown in the widget is the post-ACK value.
665
+ queueMicrotask(() => {
666
+ if (host.disposed) return;
667
+ try {
668
+ renderExocomWidget();
669
+ } catch {
670
+ /* cosmetic */
671
+ }
672
+ });
673
+ return disposition;
674
+ },
675
+ onSemantic: (frame, fromEntry) => {
676
+ if (!exocomLedgerFile) return { accepted: false, reason: "ledger unavailable" };
677
+ if (frame.kind === "claim" && fromEntry.workspace_id !== undefined && fromEntry.workspace_id !== scope.scopeWorkspaceId) {
678
+ return { accepted: false, reason: "external-workspace peers cannot claim paths in the selected scope's workspace ledger" };
679
+ }
680
+ if (exocomSeen?.seenBefore(frame.from_session, frame.msg_id)) return { accepted: true, duplicate: true };
681
+ const bytes = Buffer.byteLength(JSON.stringify(frame), "utf8");
682
+ if (!exocomBudget?.allow(frame.from_session, bytes)) {
683
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
684
+ return { accepted: false, reason: "semantic rate budget" };
685
+ }
686
+ let state: LedgerState;
687
+ try {
688
+ state = currentLedgerState();
689
+ } catch (error) {
690
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
691
+ return { accepted: false, reason: error instanceof Error ? error.message : "ledger unavailable" };
692
+ }
693
+ const label = exocomPlane?.humanDisplayLabelFor(fromEntry) ?? fromEntry.session_id;
694
+ if (frame.kind === "ask") {
695
+ if (frame.to_session !== sessionId) {
696
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
697
+ return { accepted: false, reason: "ask addressed to another session" };
698
+ }
699
+ const stored = state.asks.find((ask) => ask.msg_id === frame.msg_id && ask.ask_id === frame.ask_id && ask.to_session === sessionId);
700
+ if (!stored) {
701
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
702
+ return { accepted: false, reason: "ask is not durable in the shared ledger" };
703
+ }
704
+ const block = pendingAskBlock([stored], () => label);
705
+ if (block) exocomNotifier?.notify(block);
706
+ } else if (frame.kind === "answer") {
707
+ const stored = answerFor(state, frame.ask_id, sessionId);
708
+ if (!stored || stored.msg_id !== frame.msg_id || stored.work_key !== frame.work_key) {
709
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
710
+ return { accepted: false, reason: "answer is not durable for this requester" };
711
+ }
712
+ if (!fireWaiters(stored, label)) exocomNotifier?.notify(renderLedgerAnswer(stored, label));
713
+ } else if (!state.seen.includes(frame.msg_id)) {
714
+ exocomSeen?.forget(frame.from_session, frame.msg_id);
715
+ return { accepted: false, reason: "semantic event is not durable in the shared ledger" };
716
+ }
717
+ return { accepted: true };
718
+ },
719
+ onPoolChange: () => renderExocomWidget(),
720
+ });
721
+ await exocomPlane.start();
722
+ refreshIdentity = () => exocomHeartbeatTick(agentDir, hash, sessionId, ep, ctx.cwd);
723
+ renderExocomWidget();
724
+ // A throw from a bare timer callback is an uncaughtException — it would take the whole host
725
+ // session down over a transient registry write error (a full volume, an AV-held destination,
726
+ // a prune racing our rename). exocom failures must never block a normal session, so the tick
727
+ // is contained here; a failure that PERSISTS is still surfaced (see the report policy) rather
728
+ // than leaving the user with a plane no peer can see.
729
+ exocomHeartbeat = setInterval(() => {
730
+ try {
731
+ exocomHeartbeatTick(agentDir, hash, sessionId, ep, ctx.cwd);
732
+ exocomHeartbeatFailures = 0;
733
+ } catch (err) {
734
+ exocomHeartbeatFailures += 1;
735
+ const message = err instanceof Error ? err.message : String(err);
736
+ if (process.env.PI_PERSONA_DEBUG) {
737
+ process.stderr.write(`[pi-persona] exocom: heartbeat failed (${exocomHeartbeatFailures}×): ${message}\n`);
738
+ }
739
+ if (shouldReportHeartbeatFailure(exocomHeartbeatFailures)) {
740
+ try {
741
+ host.lastCtx?.ui.notify(
742
+ `exocom: heartbeat failed ${exocomHeartbeatFailures}× in a row (${message}) — peers drop this instance from the pool until it recovers.`,
743
+ "warning",
744
+ );
745
+ } catch {
746
+ /* cosmetic */
747
+ }
748
+ }
749
+ }
750
+ }, EXOCOM.HEARTBEAT_MS);
751
+ exocomHeartbeat.unref?.();
752
+ // Makes maxDeliveries a per-MINUTE ceiling (R6) instead of a one-shot lifetime cap.
753
+ exocomResetTimer = setInterval(() => exocomNotifier?.resetDeliveries(), 60_000);
754
+ exocomResetTimer.unref?.();
755
+ // I3: a LIVE accessor, not the plane object itself — `stopExocom` nulls `exocomPlane` on a
756
+ // `canUseBus` downgrade, and the tool bodies re-read it on every call, failing closed
757
+ // once it's gone (pi has no `unregisterTool`, so this is how revocation is made real).
758
+ registerExocomTools(pi, () => exocomPlane, (raw) => {
759
+ return host.identity.rename(raw, host.lastCtx ?? ctx);
760
+ }, () => exocomScope);
761
+ registerExocomWorkTools(pi, {
762
+ getPlane: () => exocomPlane,
763
+ sessionId: () => sessionId,
764
+ name: () => host.identity.name,
765
+ now: () => Date.now(),
766
+ canClaim: () => exocomScope?.joined !== true,
767
+ resolveTarget: (target) => {
768
+ const plane = exocomPlane;
769
+ if (!plane) throw new Error("exocom is not active for this persona");
770
+ return plane.resolvePeer(target).session_id;
771
+ },
772
+ dispatch: dispatchSemantic,
773
+ armWait: armExocomWait,
774
+ ledger: () => currentLedgerState(),
775
+ labelFor: peerLabelFor,
776
+ });
777
+ } catch (err) {
778
+ // `plane.start()` may have bound its server before a later registry write failed.
779
+ // Teardown while the reference is still owned; this also cancels notifier/timers.
780
+ await stopExocom();
781
+ const message = err instanceof Error ? err.message : String(err);
782
+ let surfacedInUi = false;
783
+ if (ctx.hasUI) {
784
+ try {
785
+ ctx.ui.notify(`exocom failed to start: ${message}`, "error");
786
+ surfacedInUi = true;
787
+ } catch {
788
+ /* the session may already be tearing down; stderr remains available */
789
+ }
790
+ }
791
+ if (!surfacedInUi || process.env.PI_PERSONA_DEBUG) {
792
+ process.stderr.write(`[pi-persona] exocom: failed to start: ${message}\n`);
793
+ }
794
+ }
795
+ }
796
+
797
+ function syncExocomActiveTools(): void {
798
+ try {
799
+ const available = new Set(pi.getAllTools().map((tool) => tool.name));
800
+ const active = new Set(pi.getActiveTools());
801
+ const caps = host.controller.capabilities;
802
+ const enabled = exocomPlane !== undefined && canParticipateInExocom(caps);
803
+ let changed = false;
804
+ for (const name of EXOCOM_TOOL_NAMES) {
805
+ if (!available.has(name)) continue;
806
+ const scopeAllows = name !== "exocom_claim" || exocomScope?.joined !== true;
807
+ const allowed = enabled && scopeAllows && (!caps || canCallTool(caps, name));
808
+ if (allowed && !active.has(name)) {
809
+ active.add(name);
810
+ changed = true;
811
+ } else if (!allowed && active.delete(name)) {
812
+ changed = true;
813
+ }
814
+ }
815
+ if (changed) pi.setActiveTools([...active]);
816
+ } catch {
817
+ /* a stale runtime is already shutting down; live tool getters still fail closed */
818
+ }
819
+ }
820
+
821
+ // Idempotent: recompute the gate and start/stop the plane to match. `startExocom` only ever
822
+ // runs at `session_start`, so a mid-session persona change (a/persona switch, the f8 cycle)
823
+ // would otherwise leave a stale decision in place until the next heartbeat (up to
824
+ // HEARTBEAT_MS later) — or, worse, never revoke a plane that should now be denied
825
+ // (containment leak: the persona changed to one whose canUseBus is false, but the plane, its
826
+ // tools, and inbound delivery all keep running under the OLD identity). Every other canUseBus
827
+ // consumer (engine/adapter.ts, engine/inproc.ts, the buildEngine call sites) reads
828
+ // `host.controller.capabilities` FRESH at bind time — this does the same for exocom. Already
829
+ // running and still gated on ⇒ a no-op (the heartbeat already relabels under the new persona).
830
+ async function applyExocomGate(ctx: ExtensionContext): Promise<void> {
831
+ const shouldRun = exocomRequested() && canParticipateInExocom(host.controller.capabilities);
832
+ if (shouldRun && !exocomPlane) await startExocom(ctx);
833
+ else if (!shouldRun && exocomPlane) await stopExocom();
834
+ syncExocomActiveTools();
835
+ }
836
+ // Lifecycle transitions run ONE AT A TIME. `startExocom` publishes `exocomPlane` synchronously
837
+ // but then suspends inside `plane.start()`'s bind, so an overlapping transition (rapid f8
838
+ // cycling into a canUseBus:false persona; a Ctrl+C during startup) would otherwise tear down a
839
+ // plane that has not finished starting: `plane.stop()` closes nothing (no server assigned yet),
840
+ // then `start()` resumes and keeps a bound socket plus a fresh registry entry for a plane the
841
+ // extension has already discarded. EVERY start/stop goes through this queue — the shutdown
842
+ // teardown included, since it is the transition most likely to race a still-pending start.
843
+ let exocomReconcile: Promise<void> = Promise.resolve();
844
+ function queueExocom(op: () => Promise<void>): Promise<void> {
845
+ const next = exocomReconcile.then(op);
846
+ // A rejected transition must not wedge every later one behind it (startExocom already
847
+ // degrades on its own; this only keeps the queue's tail alive).
848
+ exocomReconcile = next.catch(() => {});
849
+ return next;
850
+ }
851
+ function reconcileExocom(ctx: ExtensionContext): Promise<void> {
852
+ return queueExocom(() => applyExocomGate(ctx));
853
+ }
854
+
855
+ // Every mid-session persona change funnels through here. Besides re-gating exocom, the incoming
856
+ // persona starts with a clean by-hand run: the nudge's streak, cumulative burn, and backoff
857
+ // belong to the persona that accumulated them (nudge.ts's reset contract), so leaving them in
858
+ // place would bill persona A's sweep to B's first command — and let A's fired nudges suppress
859
+ // B's legitimate early ones.
860
+ async function onPersonaChanged(ctx: ExtensionContext): Promise<void> {
861
+ host.delegationNudge.reset();
862
+ await reconcileExocom(ctx);
863
+ }
864
+
865
+ // Clean shutdown: stop timers, best-effort `bye` + registry cleanup (plane.stop()). Pi's own
866
+ // session_shutdown already fires on Ctrl+C/SIGHUP/SIGTERM (not just a normal exit), so wiring
867
+ // teardown only here — like the broker teardown above — covers every exit path without a
868
+ // redundant raw process.on(SIGINT/SIGTERM) handler.
869
+ async function stopExocom(): Promise<void> {
870
+ if (exocomHeartbeat) {
871
+ clearInterval(exocomHeartbeat);
872
+ exocomHeartbeat = undefined;
873
+ }
874
+ exocomHeartbeatFailures = 0; // a later re-start reports its own first failure, not this plane's tail
875
+ if (exocomResetTimer) {
876
+ clearInterval(exocomResetTimer);
877
+ exocomResetTimer = undefined;
878
+ }
879
+ exocomNotifier?.cancel();
880
+ exocomNotifier = undefined;
881
+ exocomWaitNotifier?.cancel();
882
+ exocomWaitNotifier = undefined;
883
+ for (const waiter of exocomWaiters) clearTimeout(waiter.handle);
884
+ exocomWaiters.length = 0;
885
+ // A clean shutdown best-effort relinquishes every slice/question owned by this exact session.
886
+ // Crash or write-failure recovery is the registry-liveness prune.
887
+ if (exocomLedgerFile && exocomSessionId) {
888
+ try {
889
+ const state = currentLedgerState();
890
+ const workKeys = new Set([
891
+ ...state.claims.filter((claim) => claim.from_session === exocomSessionId).map((claim) => claim.work_key),
892
+ ...state.asks.filter((ask) => ask.from_session === exocomSessionId).map((ask) => ask.work_key),
893
+ ]);
894
+ for (const work_key of workKeys) {
895
+ const prune = currentLedgerPruneOptions();
896
+ commitLedgerEvent(exocomLedgerFile, {
897
+ kind: "release",
898
+ work_key,
899
+ from_session: exocomSessionId,
900
+ from_name: host.identity.name || exocomSessionId,
901
+ msg_id: randomUUID(),
902
+ ts: new Date().toISOString(),
903
+ }, prune ? { prune } : {});
904
+ }
905
+ } catch {
906
+ /* Teardown stays best-effort; registry lease reconciliation handles a crash-equivalent. */
907
+ }
908
+ }
909
+ exocomLedgerFile = "";
910
+ exocomSessionId = "";
911
+ writeWarnings.clear();
912
+ exocomInboxFull = false; // a later plane reports its own first full inbox, not this one's tail
913
+ exocomToastNotifier?.cancel();
914
+ exocomToastNotifier = undefined;
915
+ exocomBudget = undefined;
916
+ exocomSeen = undefined;
917
+ if (exocomPlane) {
918
+ const plane = exocomPlane;
919
+ exocomPlane = undefined;
920
+ try {
921
+ publishTelemetryPeers([]);
922
+ } catch {
923
+ /* the dying instance's last roster must not linger as live presence */
924
+ }
925
+ try {
926
+ await plane.stop();
927
+ } catch {
928
+ /* never block shutdown on a teardown error */
929
+ }
930
+ }
931
+ try {
932
+ host.lastCtx?.ui.setWidget("persona-exocom", undefined, { placement: "aboveEditor" });
933
+ } catch {
934
+ /* cosmetic */
935
+ }
936
+ try {
937
+ host.lastCtx?.ui.setStatus("persona-exocom", undefined);
938
+ } catch {
939
+ /* cosmetic */
940
+ }
941
+ refreshIdentity = undefined;
942
+ syncExocomActiveTools();
943
+ }
944
+ return {
945
+ refreshIdentity() { refreshIdentity?.(); },
946
+ reconcile: reconcileExocom,
947
+ queue: queueExocom,
948
+ stop: stopExocom,
949
+ onPersonaChanged,
950
+ syncActiveTools: syncExocomActiveTools,
951
+ telemetrySessionId,
952
+ currentTelemetryInstance,
953
+ publishTelemetryPeers,
954
+ get plane() { return exocomPlane; },
955
+ get name() { return host.identity.name; },
956
+ get namedByModel() { return host.identity.chosen; },
957
+ get scope() { return exocomScope; },
958
+ renderWidget: renderExocomWidget,
959
+ get ledgerFile() { return exocomLedgerFile; },
960
+ pendingAsks: pendingAsksFor,
961
+ pendingAskPrompt: pendingAskPromptFor,
962
+ ledgerState: currentLedgerState,
963
+ get sessionId() { return exocomSessionId; },
964
+ peerLabelFor,
965
+ writeWarnings,
966
+ get notifier() { return exocomNotifier; },
967
+ get waitNotifier() { return exocomWaitNotifier; },
968
+ kickWait() { exocomWaitNotifier?.kick(); },
969
+ };
970
+ }