@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,407 @@
1
+ /**
2
+ * Broker host — the supervisor-side relay (spec B2): listens on the session-scoped
3
+ * endpoint (`paths.ts`) and RELAYS every connected client's frames into (and out of) the
4
+ * local `InProcessBus`, so a remote (child-process) participant is indistinguishable from
5
+ * an in-process one to the rest of pi-persona (intercom, idle notifier, f9, peek, …) —
6
+ * the "same surface" promise `bus/inproc.ts`'s header makes, kept literally.
7
+ *
8
+ * Inbound (client → bus): `register` proxies `bus.register(handle)` (a client claiming
9
+ * the reserved `supervisorHandle` is rejected — the in-process supervisor never dials in,
10
+ * so a remote impersonating it would hijack the supervisor's own inbox); `send` becomes
11
+ * `bus.send`, or — when `expectsReply` — `bus.ask` run ON BEHALF of the remote sender (the
12
+ * bus mints its own ask id; the host correlates it back to the client's `msgId` via the
13
+ * settled promise, so an eventual `bus.reply`/local answer produces a `replied` frame);
14
+ * `cancel` aborts only the ask owned by that same socket; `reply` answers a pending ask via
15
+ * `bus.reply`; `list` answers a `peers` frame scoped to
16
+ * the caller's own registered group (B7); `bye`/socket close proxies `bus.unregister`.
17
+ *
18
+ * Outbound (bus → client): one `bus.onMessage` subscription drains (`bus.takeWhere`,
19
+ * mirroring the in-process delivery bridge in `engine/inproc.ts`) any envelope addressed
20
+ * to a connected remote handle — both kinds, since the host is that handle's ONLY
21
+ * consumer — and forwards it as a `deliver` frame (a blocking ask included: the remote
22
+ * side answers with a `reply` frame, resolved via the same `bus.reply` path above).
23
+ *
24
+ * Cross-process safety mirrors comtac's `broker/embedded.ts` MINUS its 12-attempt host
25
+ * election (the supervisor is always the sole host, spec B1): POSIX stale-socket reclaim
26
+ * before `listen` (unlink only a socket that is provably not connectable), a permanent
27
+ * `error` sink on every server/socket, `server.unref()` so the host never keeps Pi alive,
28
+ * idempotent teardown.
29
+ */
30
+
31
+ import { existsSync, unlinkSync } from "node:fs";
32
+ import nodeNet from "node:net";
33
+ import type net from "node:net";
34
+
35
+ import { createFrameReader, encodeFrame } from "./framing.ts";
36
+ import { type Frame, isFrame } from "./messages.ts";
37
+ import type { InProcessBus } from "../inproc.ts";
38
+
39
+ export interface BrokerHost {
40
+ endpoint: string;
41
+ close(): Promise<void>;
42
+ connectedHandles(): string[];
43
+ /** Pushes a `steer` frame directly to a connected handle (spec B4) — the supervisor's own
44
+ * live voice, NOT a bus envelope (it bypasses `bus.send`/the outbound drain entirely, so
45
+ * it can't be mistaken for a peer/child message on the wire). Returns false (a harmless
46
+ * no-op, mirrors `InProcessBus.reply`'s unknown-id return) when the handle is not
47
+ * currently connected AND was never `expect()`ed — e.g. a typo, or already forgotten.
48
+ * A handle `expect()`ed during spawn-before-connect buffers the steer and still returns
49
+ * true; the buffer flushes when the child registers. */
50
+ steer(handle: string, text: string): boolean;
51
+ /** Mark `handle` as a child that is about to connect (adapter pre-register). A steer
52
+ * to an expected-but-not-yet-connected handle is buffered rather than reported as lost. */
53
+ expect(handle: string): void;
54
+ /** Drop expectation + any buffered steers (adapter unregister / spawn failure). */
55
+ forget(handle: string): void;
56
+ }
57
+
58
+ type PeerEntry = { handle: string; label: string };
59
+
60
+ export interface StartBrokerHostDeps {
61
+ bus: InProcessBus;
62
+ /** Reserved — `register{handle: supervisorHandle}` from a client is rejected. */
63
+ supervisorHandle: string;
64
+ endpoint: string;
65
+ /** Injected transport (tests use a paired in-memory fake); defaults to real `node:net`. */
66
+ net?: typeof import("node:net");
67
+ /** Answers a `list` frame; default reads the host's own `peers:true` registrations. */
68
+ listPeersFor?: (group: string, self: string) => PeerEntry[];
69
+ }
70
+
71
+ const STALE_PROBE_TIMEOUT_MS = 1000;
72
+
73
+ /** Probe a POSIX socket path: is something actually listening there? */
74
+ function isConnectable(netImpl: typeof import("node:net"), endpoint: string): Promise<boolean> {
75
+ return new Promise((resolve) => {
76
+ const socket = netImpl.connect(endpoint);
77
+ const finish = (ok: boolean): void => {
78
+ clearTimeout(timer);
79
+ socket.removeListener("connect", onConnect);
80
+ socket.removeListener("error", onError);
81
+ socket.destroy();
82
+ resolve(ok);
83
+ };
84
+ const onConnect = (): void => finish(true);
85
+ const onError = (): void => finish(false);
86
+ socket.once("connect", onConnect);
87
+ socket.once("error", onError);
88
+ const timer = setTimeout(() => finish(false), STALE_PROBE_TIMEOUT_MS);
89
+ });
90
+ }
91
+
92
+ type ListenAttempt = { server: net.Server } | { code: string };
93
+
94
+ /** One bind attempt on a FRESH server (a server that failed to listen is not reused —
95
+ * mirrors comtac's `listenOnce`). Wires the connection handler before `listen()` so no
96
+ * connection can race the handler being attached. On success: permanent error sink + unref. */
97
+ function attemptListen(netImpl: typeof import("node:net"), endpoint: string, onConnection: (socket: net.Socket) => void): Promise<ListenAttempt> {
98
+ return new Promise((resolve) => {
99
+ const server = netImpl.createServer();
100
+ server.on("connection", onConnection);
101
+ const onError = (err: NodeJS.ErrnoException): void => {
102
+ server.removeListener("listening", onListening);
103
+ try {
104
+ server.close();
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ resolve({ code: err.code ?? "EUNKNOWN" });
109
+ };
110
+ const onListening = (): void => {
111
+ server.removeListener("error", onError);
112
+ server.on("error", () => {
113
+ /* permanent sink: a late server error must never crash the host */
114
+ });
115
+ server.unref();
116
+ resolve({ server });
117
+ };
118
+ server.once("error", onError);
119
+ server.once("listening", onListening);
120
+ try {
121
+ server.listen(endpoint);
122
+ } catch (err) {
123
+ resolve({ code: (err as NodeJS.ErrnoException).code ?? "EUNKNOWN" });
124
+ }
125
+ });
126
+ }
127
+
128
+ /** Bind `endpoint`, reclaiming a stale POSIX socket file once (comtac `embedded.ts:103-114`,
129
+ * minus the host-election loop — the supervisor is the sole host). */
130
+ async function bindServer(netImpl: typeof import("node:net"), endpoint: string, onConnection: (socket: net.Socket) => void): Promise<net.Server> {
131
+ let attempt = await attemptListen(netImpl, endpoint, onConnection);
132
+ if (
133
+ !("server" in attempt) &&
134
+ attempt.code === "EADDRINUSE" &&
135
+ process.platform !== "win32" &&
136
+ existsSync(endpoint) &&
137
+ !(await isConnectable(netImpl, endpoint))
138
+ ) {
139
+ try {
140
+ unlinkSync(endpoint);
141
+ } catch {
142
+ /* another session may have reaped it first */
143
+ }
144
+ attempt = await attemptListen(netImpl, endpoint, onConnection);
145
+ }
146
+ if (!("server" in attempt)) {
147
+ throw new Error(`broker host: failed to listen on ${endpoint} (${attempt.code})`);
148
+ }
149
+ return attempt.server;
150
+ }
151
+
152
+ export async function startBrokerHost(deps: StartBrokerHostDeps): Promise<BrokerHost> {
153
+ const netImpl = deps.net ?? nodeNet;
154
+ const bus = deps.bus;
155
+
156
+ const connections = new Map<string, { write: (frame: Frame) => void }>();
157
+ const peerRegistry = new Map<string, { handle: string; label: string; group: string }>();
158
+ const sockets = new Set<net.Socket>();
159
+ const expected = new Set<string>();
160
+ const pendingSteer = new Map<string, string[]>();
161
+
162
+ function flushPendingSteer(handle: string): void {
163
+ const conn = connections.get(handle);
164
+ const queued = pendingSteer.get(handle);
165
+ if (!conn || !queued) return;
166
+ pendingSteer.delete(handle);
167
+ for (const text of queued) conn.write({ t: "steer", text });
168
+ }
169
+
170
+ function unregisterConnection(handle: string): void {
171
+ if (!connections.delete(handle)) return;
172
+ peerRegistry.delete(handle);
173
+ bus.unregister(handle);
174
+ }
175
+
176
+ function defaultPeersFor(group: string, self: string): PeerEntry[] {
177
+ return [...peerRegistry.values()]
178
+ .filter((p) => p.group === group && p.handle !== self)
179
+ .map((p) => ({ handle: p.handle, label: p.label }));
180
+ }
181
+
182
+ /** Forward every queued envelope addressed to `handle` as a `deliver` frame — both
183
+ * kinds, blocking ask included (the host is this handle's only consumer). */
184
+ function drain(handle: string): void {
185
+ const conn = connections.get(handle);
186
+ if (!conn) return;
187
+ for (const env of bus.takeWhere(handle, () => true)) {
188
+ const fromLabel = peerRegistry.get(env.from)?.label;
189
+ conn.write({
190
+ t: "deliver",
191
+ from: env.from,
192
+ kind: env.kind,
193
+ text: env.text,
194
+ msgId: env.id,
195
+ expectsReply: env.expectsReply,
196
+ ...(fromLabel ? { fromLabel } : {}),
197
+ });
198
+ }
199
+ }
200
+
201
+ const unsubBus = bus.onMessage((env) => {
202
+ if (connections.has(env.to)) drain(env.to);
203
+ });
204
+
205
+ function handleConnection(socket: net.Socket): void {
206
+ sockets.add(socket);
207
+ let handle: string | undefined;
208
+ let group: string | undefined;
209
+ const pendingAsks = new Map<string, AbortController>();
210
+
211
+ const write = (frame: Frame): void => {
212
+ try {
213
+ socket.write(encodeFrame(frame));
214
+ } catch {
215
+ /* peer went away mid-write */
216
+ }
217
+ };
218
+ /** This connection's identity in `connections` — compared on cleanup so a socket only
219
+ * ever tears down a registration it still OWNS. */
220
+ const conn = { write };
221
+
222
+ const cleanup = (): void => {
223
+ for (const ac of pendingAsks.values()) ac.abort();
224
+ pendingAsks.clear();
225
+ // `bye` releases the handle while `handle` stays set (the socket's own `close`
226
+ // runs cleanup again, asynchronously); by then another connection may legitimately
227
+ // have claimed the freed handle, and unregistering blind would kill ITS live entry.
228
+ if (handle && connections.get(handle) === conn) unregisterConnection(handle);
229
+ };
230
+ const forgetAsk = (msgId: string, ac: AbortController): void => {
231
+ if (pendingAsks.get(msgId) === ac) pendingAsks.delete(msgId);
232
+ };
233
+ const errorReason = (error: unknown): string => error instanceof Error ? error.message : String(error);
234
+
235
+ const dispatch = (frame: Frame): void => {
236
+ switch (frame.t) {
237
+ case "register": {
238
+ if (frame.handle === deps.supervisorHandle) {
239
+ write({ t: "error", reason: "reserved handle" });
240
+ return;
241
+ }
242
+ // Registration is one-shot per socket and exclusive per handle: a
243
+ // second claim would overwrite the live entry, and this socket's close
244
+ // would then unregister a handle it no longer owns (or leak the one it
245
+ // silently abandoned).
246
+ if (handle) {
247
+ write({ t: "error", reason: "already registered" });
248
+ return;
249
+ }
250
+ if (connections.has(frame.handle)) {
251
+ write({ t: "error", reason: `handle already connected: ${frame.handle}` });
252
+ return;
253
+ }
254
+ handle = frame.handle;
255
+ group = frame.group;
256
+ bus.register(handle);
257
+ connections.set(handle, conn);
258
+ if (frame.peers) peerRegistry.set(handle, { handle, label: frame.label ?? handle, group: frame.group ?? "" });
259
+ write({ t: "registered", handle });
260
+ drain(handle);
261
+ flushPendingSteer(handle);
262
+ return;
263
+ }
264
+ case "send": {
265
+ if (!handle) {
266
+ write({ t: "error", reason: "send before register" });
267
+ return;
268
+ }
269
+ const from = handle;
270
+ if (frame.expectsReply) {
271
+ const ac = new AbortController();
272
+ pendingAsks.set(frame.msgId, ac);
273
+ try {
274
+ bus.ask(from, frame.to, frame.text, { kind: frame.kind, signal: ac.signal }).then(
275
+ (text) => {
276
+ forgetAsk(frame.msgId, ac);
277
+ write({ t: "replied", askId: frame.msgId, text });
278
+ },
279
+ (err: unknown) => {
280
+ forgetAsk(frame.msgId, ac);
281
+ write({ t: "error", reason: errorReason(err), msgId: frame.msgId });
282
+ },
283
+ );
284
+ } catch (error) {
285
+ forgetAsk(frame.msgId, ac);
286
+ write({ t: "error", reason: errorReason(error), msgId: frame.msgId });
287
+ }
288
+ } else if (!bus.send(from, frame.to, frame.text, frame.kind)) {
289
+ write({ t: "error", reason: `unknown peer: ${frame.to}` });
290
+ }
291
+ return;
292
+ }
293
+ case "cancel": {
294
+ if (!handle) {
295
+ write({ t: "error", reason: "cancel before register", msgId: frame.msgId });
296
+ return;
297
+ }
298
+ // The map is per socket/registered participant. A child can only cancel an
299
+ // ask that this same connection originated; an id from another peer is ignored.
300
+ pendingAsks.get(frame.msgId)?.abort();
301
+ return;
302
+ }
303
+ case "reply": {
304
+ bus.reply(frame.askId, frame.text);
305
+ return;
306
+ }
307
+ case "list": {
308
+ const self = handle ?? "";
309
+ const g = group ?? "";
310
+ const peers = deps.listPeersFor ? deps.listPeersFor(g, self) : defaultPeersFor(g, self);
311
+ write({ t: "peers", reqId: frame.reqId, peers });
312
+ return;
313
+ }
314
+ case "bye": {
315
+ cleanup();
316
+ socket.end();
317
+ return;
318
+ }
319
+ default:
320
+ return; // host→client-only frame types received from a client are ignored
321
+ }
322
+ };
323
+
324
+ const handleFrame = (raw: unknown): void => {
325
+ if (!isFrame(raw)) {
326
+ write({ t: "error", reason: "invalid frame" });
327
+ return;
328
+ }
329
+ try {
330
+ dispatch(raw);
331
+ } catch (e) {
332
+ write({
333
+ t: "error",
334
+ reason: e instanceof Error ? e.message : String(e),
335
+ ...(raw.t === "send" && raw.expectsReply ? { msgId: raw.msgId } : {}),
336
+ });
337
+ }
338
+ };
339
+
340
+ socket.on("data", createFrameReader(handleFrame, (e) => {
341
+ write({ t: "error", reason: e.message });
342
+ socket.destroy();
343
+ }));
344
+ socket.on("error", () => socket.destroy());
345
+ socket.once("close", () => {
346
+ sockets.delete(socket);
347
+ cleanup();
348
+ });
349
+ }
350
+
351
+ const server = await bindServer(netImpl, deps.endpoint, handleConnection);
352
+
353
+ let closed = false;
354
+ return {
355
+ endpoint: deps.endpoint,
356
+ connectedHandles: () => [...connections.keys()],
357
+ steer(handle: string, text: string): boolean {
358
+ const conn = connections.get(handle);
359
+ if (conn) {
360
+ conn.write({ t: "steer", text });
361
+ return true;
362
+ }
363
+ if (!expected.has(handle) || !text.trim()) return false;
364
+ const queued = pendingSteer.get(handle) ?? [];
365
+ queued.push(text);
366
+ pendingSteer.set(handle, queued);
367
+ return true;
368
+ },
369
+ expect(handle: string): void {
370
+ expected.add(handle);
371
+ },
372
+ forget(handle: string): void {
373
+ expected.delete(handle);
374
+ pendingSteer.delete(handle);
375
+ },
376
+ async close(): Promise<void> {
377
+ if (closed) return;
378
+ closed = true;
379
+ unsubBus();
380
+ expected.clear();
381
+ pendingSteer.clear();
382
+ for (const h of [...connections.keys()]) unregisterConnection(h);
383
+ for (const s of [...sockets]) {
384
+ try {
385
+ s.destroy();
386
+ } catch {
387
+ /* ignore */
388
+ }
389
+ }
390
+ sockets.clear();
391
+ await new Promise<void>((resolve) => {
392
+ try {
393
+ server.close(() => resolve());
394
+ } catch {
395
+ resolve();
396
+ }
397
+ });
398
+ if (process.platform !== "win32") {
399
+ try {
400
+ unlinkSync(deps.endpoint);
401
+ } catch {
402
+ /* already gone, or this was a fake/in-memory endpoint */
403
+ }
404
+ }
405
+ },
406
+ };
407
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Slimmed wire-frame catalog + validators (spec B6) — comtac's protocol trimmed to the
3
+ * frames pi-persona actually needs: register/send/cancel/reply/list/bye (client→host) and
4
+ * registered/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
5
+ * `messages.ts` validation STYLE (structural per-field checks, one explicit assert per
6
+ * case) but drops presence/sessions/attachments/broadcasts entirely (YAGNI here).
7
+ *
8
+ * Pure module: no Pi imports, unit-tested.
9
+ */
10
+
11
+ import type { MsgKind } from "../inproc.ts";
12
+
13
+ export type Frame =
14
+ | { t: "register"; handle: string; label?: string; group?: string; peers?: boolean }
15
+ | { t: "registered"; handle: string }
16
+ | { t: "send"; to: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
17
+ | { t: "cancel"; msgId: string }
18
+ | { t: "deliver"; from: string; fromLabel?: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
19
+ | { t: "reply"; askId: string; text: string }
20
+ | { t: "replied"; askId: string; text: string }
21
+ | { t: "steer"; text: string }
22
+ | { t: "list"; reqId: string }
23
+ | { t: "peers"; reqId: string; peers: Array<{ handle: string; label: string }> }
24
+ | { t: "bye" }
25
+ | { t: "error"; reason: string; msgId?: string };
26
+
27
+ function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+
31
+ function isMsgKind(value: unknown): value is MsgKind {
32
+ return value === "progress" || value === "decision" || value === "interview";
33
+ }
34
+
35
+ function isPeerEntry(value: unknown): value is { handle: string; label: string } {
36
+ return isRecord(value) && typeof value.handle === "string" && typeof value.label === "string";
37
+ }
38
+
39
+ export function isFrame(value: unknown): value is Frame {
40
+ if (!isRecord(value)) return false;
41
+ switch (value.t) {
42
+ case "register": {
43
+ if (typeof value.handle !== "string") return false;
44
+ if (value.label !== undefined && typeof value.label !== "string") return false;
45
+ if (value.group !== undefined && typeof value.group !== "string") return false;
46
+ return value.peers === undefined || typeof value.peers === "boolean";
47
+ }
48
+ case "registered":
49
+ return typeof value.handle === "string";
50
+ case "send":
51
+ return (
52
+ typeof value.to === "string" &&
53
+ isMsgKind(value.kind) &&
54
+ typeof value.text === "string" &&
55
+ typeof value.msgId === "string" &&
56
+ typeof value.expectsReply === "boolean"
57
+ );
58
+ case "cancel":
59
+ return typeof value.msgId === "string";
60
+ case "deliver": {
61
+ if (typeof value.from !== "string") return false;
62
+ if (value.fromLabel !== undefined && typeof value.fromLabel !== "string") return false;
63
+ return (
64
+ isMsgKind(value.kind) &&
65
+ typeof value.text === "string" &&
66
+ typeof value.msgId === "string" &&
67
+ typeof value.expectsReply === "boolean"
68
+ );
69
+ }
70
+ case "reply":
71
+ return typeof value.askId === "string" && typeof value.text === "string";
72
+ case "replied":
73
+ return typeof value.askId === "string" && typeof value.text === "string";
74
+ case "steer":
75
+ return typeof value.text === "string";
76
+ case "list":
77
+ return typeof value.reqId === "string";
78
+ case "peers":
79
+ return typeof value.reqId === "string" && Array.isArray(value.peers) && value.peers.every(isPeerEntry);
80
+ case "bye":
81
+ return true;
82
+ case "error":
83
+ return typeof value.reason === "string" && (value.msgId === undefined || typeof value.msgId === "string");
84
+ default:
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Session-scoped broker endpoint — the supervisor hosts one bus PER SESSION, so two
3
+ * concurrent supervisors never share it (spec B1). Windows uses a named pipe, every other
4
+ * platform a Unix domain socket under the user's Pi dir. Pure (home/platform injectable).
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ /** AF_UNIX `sun_path` is 104 bytes on macOS/BSD and 108 on Linux, NUL included — a socket
12
+ * path over the smallest of those makes both `listen` and `connect` fail outright. */
13
+ const MAX_SUN_PATH = 103;
14
+
15
+ export function sanitizeSegment(s: string): string {
16
+ return s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
17
+ }
18
+
19
+ export function brokerEndpoint(sessionId: string, home: string = homedir(), platform: NodeJS.Platform = process.platform): string {
20
+ const sid = sanitizeSegment(sessionId) || "default";
21
+ if (platform === "win32") return `\\\\.\\pipe\\pi-persona-${sanitizeSegment(home)}-${sid}`;
22
+ const dir = join(home, ".pi", "agent", "persona");
23
+ const endpoint = join(dir, `broker-${sid}.sock`);
24
+ if (Buffer.byteLength(endpoint) <= MAX_SUN_PATH) return endpoint;
25
+ // Session ids are uuidv7 (36 chars), so an ordinary-but-longish $HOME already pushes the
26
+ // readable name past sun_path; a hashed one is short and still derived purely from the
27
+ // session id, so host and client compute the same endpoint independently. The hashed form
28
+ // costs `$HOME` + 41 bytes, so a $HOME past 62 bytes stays out of reach either way —
29
+ // nothing under the user's own dir would fit.
30
+ return join(dir, `b-${createHash("sha1").update(sid).digest("hex").slice(0, 12)}.sock`);
31
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `contact_supervisor` — the CHILD-side tool of the communication plane. A sub-agent
3
+ * calls it to reach its supervisor while it runs (guardrails §4.9, the comtac
4
+ * `contact_supervisor` semantic). Injected into an in-process child session as a
5
+ * `customTool` by the engine, bound to the shared {@link InProcessBus} + the child's
6
+ * handle.
7
+ *
8
+ * - `progress_update` (one-way): post and return immediately — the supervisor reads it
9
+ * on its next peek (or it rides back on the run's result in sync mode).
10
+ * - `need_decision` / `interview_request` (blocking): `bus.ask` — the child waits for
11
+ * the supervisor's reply. This only makes sense in **async** runs where the supervisor
12
+ * is free to answer; the tool aborts cleanly on the run's signal so it never hangs a
13
+ * sync run with no one listening.
14
+ *
15
+ * Pure factory (takes the bus + handles), so it is unit-tested without a live model.
16
+ */
17
+
18
+ import { type Static, Type } from "typebox";
19
+
20
+ import { defineTool } from "@earendil-works/pi-coding-agent";
21
+
22
+ import type { InProcessBus, MsgKind } from "./inproc.ts";
23
+
24
+ const ContactParams = Type.Object({
25
+ kind: Type.Union([Type.Literal("progress"), Type.Literal("decision"), Type.Literal("interview")], {
26
+ description:
27
+ "progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question. For decision/interview, async runs wait for a reply; synchronous runs post one-way and continue.",
28
+ }),
29
+ message: Type.String({ description: "What to tell or ask the supervisor — specific and self-contained." }),
30
+ });
31
+
32
+ type ContactDetails = { kind: MsgKind; delivered: boolean };
33
+
34
+ type AskFailure = "aborted" | "timeout" | "inbox-full" | "unavailable" | "generic";
35
+
36
+ function classifyAskFailure(error: unknown, signal: AbortSignal | undefined): AskFailure {
37
+ if (signal?.aborted) return "aborted";
38
+ const message = error instanceof Error ? error.message : "";
39
+ if (/^ask timeout(?:\s|$)/i.test(message)) return "timeout";
40
+ if (/^inbox full(?:\s|:|$)/i.test(message)) return "inbox-full";
41
+ if (/^ask unregistered(?:\s|$)/i.test(message) || /^broker (?:connection|client) closed(?:\s|$)/i.test(message)) return "unavailable";
42
+ return "generic";
43
+ }
44
+
45
+ function result(t: string, details: ContactDetails): { content: [{ type: "text"; text: string }]; details: ContactDetails } {
46
+ return { content: [{ type: "text", text: t }], details };
47
+ }
48
+
49
+ /** Build the child's `contact_supervisor` tool, bound to a bus + the child's handle.
50
+ * `defineTool` preserves param inference while widening the result so it slots into a
51
+ * `customTools: ToolDefinition[]` array (the variance shim Pi provides for exactly this).
52
+ *
53
+ * `allowBlocking` (async runs only): when false, a `decision`/`interview` does NOT block —
54
+ * the supervisor is busy (a sync run holds the turn), so the question is posted one-way and
55
+ * the child proceeds. This prevents a sync delegate/council from deadlocking on a child ask. */
56
+ export function makeContactSupervisorTool(
57
+ bus: InProcessBus,
58
+ fromHandle: string,
59
+ supervisorHandle: string,
60
+ opts: { allowBlocking?: boolean } = {},
61
+ ) {
62
+ const allowBlocking = opts.allowBlocking ?? true;
63
+ return defineTool({
64
+ name: "contact_supervisor",
65
+ label: "Contact supervisor",
66
+ description: [
67
+ "Reach the supervisor that delegated this task WHILE you work.",
68
+ "Use `progress` to report a milestone (one-way, don't wait).",
69
+ "Use `decision` when you hit a choice only the supervisor should make, or `interview` to ask a clarifying question.",
70
+ allowBlocking
71
+ ? "This async run waits for the supervisor's reply before continuing."
72
+ : "This synchronous run cannot block: decision/interview are posted one-way and return immediately; proceed using your best judgement.",
73
+ `Messages longer than ${MAX_CONTACT_MESSAGE_CHARS} characters are shortened before delivery; use smaller messages to preserve full detail.`,
74
+ ].join(" "),
75
+ promptSnippet: "contact_supervisor — report progress or ask the supervisor a question",
76
+ parameters: ContactParams,
77
+ async execute(_toolCallId, params: Static<typeof ContactParams>, signal, _onUpdate, _ctx) {
78
+ const kind = params.kind as MsgKind;
79
+ const message = boundContactMessage(params.message);
80
+ if (kind === "progress") {
81
+ const delivered = bus.send(fromHandle, supervisorHandle, message, "progress");
82
+ return result(
83
+ delivered ? "Progress reported to the supervisor." : "(no supervisor listening; progress dropped)",
84
+ { kind, delivered },
85
+ );
86
+ }
87
+
88
+ // In a sync run the supervisor is blocked holding the turn → it cannot answer. Post the
89
+ // question one-way and let the child proceed, rather than deadlock until timeout.
90
+ if (!allowBlocking) {
91
+ const delivered = bus.send(fromHandle, supervisorHandle, message, kind);
92
+ return result(
93
+ delivered
94
+ ? "The supervisor is busy right now; your question was noted — proceed using your best judgement."
95
+ : "(no supervisor reachable — proceed using your best judgement)",
96
+ { kind, delivered },
97
+ );
98
+ }
99
+
100
+ // decision | interview → block for the supervisor's reply. `bus.ask` honours the run
101
+ // signal (cleaning up its own timer/entry), and a rejection (abort or timeout) is
102
+ // turned into a RESULT — execute() must never throw or hang under the tool contract.
103
+ let answer: Promise<string>;
104
+ try {
105
+ answer = bus.ask(fromHandle, supervisorHandle, message, signal ? { kind, signal } : { kind });
106
+ } catch {
107
+ return result("(no supervisor reachable — proceed using your best judgement)", { kind, delivered: false });
108
+ }
109
+ try {
110
+ const reply = await answer;
111
+ return result(`Supervisor: ${reply}`, { kind, delivered: true });
112
+ } catch (error) {
113
+ const msg = (() => {
114
+ switch (classifyAskFailure(error, signal)) {
115
+ case "aborted":
116
+ return "(the request was cancelled before the supervisor replied)";
117
+ case "timeout":
118
+ return "(the supervisor did not reply in time — proceed using your best judgement)";
119
+ case "inbox-full":
120
+ return "(the supervisor inbox is full — this request was not delivered; retry later or consolidate the question)";
121
+ case "unavailable":
122
+ return "(the supervisor is no longer available — this request was not delivered; proceed using your best judgement)";
123
+ default:
124
+ return "(the supervisor request could not be completed — proceed using your best judgement)";
125
+ }
126
+ })();
127
+ return result(msg, { kind, delivered: false });
128
+ }
129
+ },
130
+ });
131
+ }
132
+ /** Maximum body accepted by contact_supervisor's message action. Full reports should use the
133
+ * delegated result channel rather than sending megabytes over the supervisor bus. */
134
+ export const MAX_CONTACT_MESSAGE_CHARS = 8_000;
135
+
136
+ /** Keep the sender-facing body bounded while preserving both ends and an omission count. */
137
+ export function boundContactMessage(message: string, maxChars = MAX_CONTACT_MESSAGE_CHARS): string {
138
+ if (message.length <= maxChars) return message;
139
+ const marker = `\n\n[… ${message.length - maxChars} characters omitted; send the remaining detail in smaller contact_supervisor messages …]\n\n`;
140
+ if (marker.length >= maxChars) return marker.slice(0, Math.max(0, maxChars));
141
+ const content = Math.max(0, maxChars - marker.length);
142
+ const head = Math.ceil(content / 2);
143
+ const tail = Math.max(0, content - head);
144
+ return `${message.slice(0, head)}${marker}${tail > 0 ? message.slice(-tail) : ""}`;
145
+ }