@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.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- 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
|
+
}
|