@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
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CHILD side of the cross-process broker (spec B3): loaded INSTEAD of the full
|
|
3
|
+
* extension when `PI_PERSONA_BUS` is set (see `extension.ts`'s factory dispatch). Connects
|
|
4
|
+
* a `BrokerClient` (relay to the supervisor's `InProcessBus`), registers `contact_supervisor`
|
|
5
|
+
* + `contact_peer` bound to the wire, and surfaces inbound frames as `pi.sendMessage`
|
|
6
|
+
* follow-ups — the same mechanism comtac's `index.ts` uses for inbound bus traffic, and the
|
|
7
|
+
* same fencing rule the in-process delivery bridge (`engine/inproc.ts`) enforces: untrusted
|
|
8
|
+
* sub-agent/peer text goes through `fenceUntrusted`, with attribution OUTSIDE the fence so a
|
|
9
|
+
* malicious payload can never spoof its own sender.
|
|
10
|
+
*
|
|
11
|
+
* A `steer` frame is NOT fenced — it is the supervisor's own live voice (the same as
|
|
12
|
+
* `onSteerable` in the in-process engine, which injects steer text unfenced), just attributed.
|
|
13
|
+
*
|
|
14
|
+
* No personas, no delegate/council, no input hooks — bridge mode is deliberately a sealed,
|
|
15
|
+
* minimal surface (the fork-bomb guard `PI_PERSONA_DISABLE=1` already keeps the full
|
|
16
|
+
* extension out of a spawned child; this only adds the connected comm plane on top).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
import { type BrokerClient, type DeliverEvent, makeBrokerClient, type MakeBrokerClientDeps } from "./bus/broker/client.ts";
|
|
22
|
+
import { makeContactSupervisorTool } from "./bus/contact.ts";
|
|
23
|
+
import { InProcessBus, type MsgKind } from "./bus/inproc.ts";
|
|
24
|
+
import { makeContactPeerTool, type PeerInfo } from "./bus/peers.ts";
|
|
25
|
+
import { attributeInbound } from "./core/fence.ts";
|
|
26
|
+
|
|
27
|
+
type Env = Record<string, string | undefined>;
|
|
28
|
+
|
|
29
|
+
/** The bus handle pi-persona's supervisor always registers under (mirrors `extension.ts`'s
|
|
30
|
+
* `SUPERVISOR` constant — a child can never BE the supervisor, `host.ts` rejects that
|
|
31
|
+
* handle on `register`, so the literal is safe to hardcode here too). */
|
|
32
|
+
const SUPERVISOR_HANDLE = "supervisor";
|
|
33
|
+
|
|
34
|
+
const BRIDGE_MESSAGE_TYPE = "pi_persona_bridge";
|
|
35
|
+
|
|
36
|
+
export interface InstallBridgeDeps {
|
|
37
|
+
/** Override for `process.env` (tests). */
|
|
38
|
+
env?: Env;
|
|
39
|
+
/** Override for `makeBrokerClient` (tests inject a fake `BrokerClient`). */
|
|
40
|
+
makeClient?: (deps: MakeBrokerClientDeps) => BrokerClient;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True iff this process was spawned as a broker-connected child (spec B3/B5). Checked at the
|
|
44
|
+
* very top of the extension factory — `PI_PERSONA_BUS` unset ⇒ zero behavior change. */
|
|
45
|
+
export function isBridgeMode(env: Env = process.env): boolean {
|
|
46
|
+
return !!env.PI_PERSONA_BUS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Routes `InProcessBus.send`/`ask` over a `BrokerClient` instead of a local mailbox — the
|
|
50
|
+
* thin adapter the bus-bound `contact_supervisor`/`contact_peer` factories need to run
|
|
51
|
+
* unmodified against a remote target. A real `InProcessBus` subclass (not a duck-typed
|
|
52
|
+
* object) so it satisfies the factories' concrete parameter type without touching them.
|
|
53
|
+
* `connected` gates both overrides so the tools' existing "no supervisor reachable" /
|
|
54
|
+
* "dropped" wording stays honest before the handshake completes — the client's own
|
|
55
|
+
* `send`/`ask` can't report that (a write to a not-yet-open socket silently no-ops, per
|
|
56
|
+
* spec's "sends report undelivered"). */
|
|
57
|
+
class BrokerBus extends InProcessBus {
|
|
58
|
+
private readonly client: BrokerClient;
|
|
59
|
+
connected = false;
|
|
60
|
+
|
|
61
|
+
constructor(client: BrokerClient) {
|
|
62
|
+
super();
|
|
63
|
+
this.client = client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override send(_from: string, to: string, text: string, kind: MsgKind = "progress"): boolean {
|
|
67
|
+
if (!this.connected) return false;
|
|
68
|
+
this.client.send(to, kind, text);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override ask(
|
|
73
|
+
_from: string,
|
|
74
|
+
to: string,
|
|
75
|
+
text: string,
|
|
76
|
+
opts: { kind?: MsgKind; timeoutMs?: number; signal?: AbortSignal } = {},
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
if (!this.connected) throw new Error("bridge: not connected to the supervisor");
|
|
79
|
+
return this.client.ask(to, opts.kind ?? "decision", text, opts.signal);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Wrap fenced, attributed follow-up text as a `pi.sendMessage` custom message (comtac's
|
|
84
|
+
* inbound-surfacing mechanism — see `index.ts`'s `surface()`). */
|
|
85
|
+
function sendFollowUp(pi: ExtensionAPI, content: string): void {
|
|
86
|
+
pi.sendMessage({ customType: BRIDGE_MESSAGE_TYPE, content, display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Install child-mode bridge wiring on `pi` — called once, from `session_start`, when
|
|
90
|
+
* `isBridgeMode()` is true. Connects the broker client (capped retries; a failed connect
|
|
91
|
+
* degrades to a mute executor, never a crash — spec §4), registers the two wire-bound
|
|
92
|
+
* comm-plane tools, surfaces inbound `deliver`/`steer` frames as follow-ups, and closes the
|
|
93
|
+
* client on `session_shutdown`. */
|
|
94
|
+
export function installBridge(pi: ExtensionAPI, ctx: ExtensionContext, deps: InstallBridgeDeps = {}): void {
|
|
95
|
+
const env = deps.env ?? process.env;
|
|
96
|
+
const endpoint = env.PI_PERSONA_BUS;
|
|
97
|
+
const handle = env.PI_PERSONA_HANDLE;
|
|
98
|
+
if (!endpoint || !handle) return; // isBridgeMode() gates the real caller; malformed env degrades to a no-op
|
|
99
|
+
|
|
100
|
+
const wantsPeers = env.PI_PERSONA_PEERS === "1";
|
|
101
|
+
const clientDeps: MakeBrokerClientDeps = { endpoint, handle, ...(wantsPeers ? { peers: true } : {}) };
|
|
102
|
+
const client = (deps.makeClient ?? makeBrokerClient)(clientDeps);
|
|
103
|
+
|
|
104
|
+
// Blocking asks are honoured only when the spawning run is async (`PI_PERSONA_ALLOW_BLOCKING`,
|
|
105
|
+
// set by adapter.ts from the strategy's `allowBlocking`) — a sync delegate/council/orchestrate
|
|
106
|
+
// turn holds the supervisor's turn, so a blocking `decision`/`interview` here would deadlock it
|
|
107
|
+
// (the same safety default the in-process engine applies; see contact.ts / spec §4.9).
|
|
108
|
+
const allowBlocking = env.PI_PERSONA_ALLOW_BLOCKING === "1";
|
|
109
|
+
const bus = new BrokerBus(client);
|
|
110
|
+
pi.registerTool(makeContactSupervisorTool(bus, handle, SUPERVISOR_HANDLE, { allowBlocking }));
|
|
111
|
+
|
|
112
|
+
// The engine-scoped peer roster (B7): fetched from the host's `list` frame and cached —
|
|
113
|
+
// `contact_peer` accepts an awaitable provider, so the first call can wait for the initial
|
|
114
|
+
// wire round-trip instead of reporting the empty pre-refresh cache. Refreshes are coalesced
|
|
115
|
+
// while one is in flight; a generation guard prevents a future overlapping transport change
|
|
116
|
+
// from applying an older response over a newer roster.
|
|
117
|
+
let peerCache: PeerInfo[] = [];
|
|
118
|
+
let bridgeReady: Promise<void> | undefined;
|
|
119
|
+
let peerRefresh: Promise<PeerInfo[]> | undefined;
|
|
120
|
+
let peerRefreshGeneration = 0;
|
|
121
|
+
const refreshPeers = (): Promise<PeerInfo[]> => {
|
|
122
|
+
if (!bus.connected) {
|
|
123
|
+
const registration = bridgeReady;
|
|
124
|
+
return registration ? registration.then(() => refreshPeers(), () => peerCache) : Promise.resolve(peerCache);
|
|
125
|
+
}
|
|
126
|
+
if (peerRefresh) return peerRefresh;
|
|
127
|
+
const generation = ++peerRefreshGeneration;
|
|
128
|
+
let request: Promise<PeerInfo[]>;
|
|
129
|
+
try {
|
|
130
|
+
request = Promise.resolve(client.list()).then(
|
|
131
|
+
(peers) => {
|
|
132
|
+
if (generation === peerRefreshGeneration) peerCache = peers;
|
|
133
|
+
return peers;
|
|
134
|
+
},
|
|
135
|
+
() => {
|
|
136
|
+
/* best-effort — keep the stale cache rather than clearing a known roster */
|
|
137
|
+
return peerCache;
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
} catch {
|
|
141
|
+
request = Promise.resolve(peerCache);
|
|
142
|
+
}
|
|
143
|
+
const shared = request.finally(() => {
|
|
144
|
+
if (peerRefresh === shared) peerRefresh = undefined;
|
|
145
|
+
});
|
|
146
|
+
peerRefresh = shared;
|
|
147
|
+
return shared;
|
|
148
|
+
};
|
|
149
|
+
pi.registerTool(
|
|
150
|
+
makeContactPeerTool(bus, handle, {
|
|
151
|
+
listPeers: () => refreshPeers(),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Inbound `deliver` — a supervisor `intercom send` or a sibling's `contact_peer` send.
|
|
156
|
+
// Fenced (untrusted text crossing a process boundary), attribution OUTSIDE the fence —
|
|
157
|
+
// exactly the in-process delivery bridge's rule (`engine/inproc.ts`).
|
|
158
|
+
client.onDeliver((evt: DeliverEvent) => {
|
|
159
|
+
const from = evt.from === SUPERVISOR_HANDLE ? "your supervisor" : `peer ${evt.fromLabel ?? evt.from}`;
|
|
160
|
+
sendFollowUp(pi, attributeInbound(from, evt.text));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Inbound `steer` — the supervisor's own live redirect (`intercom steer` / f9 `s`), queued
|
|
164
|
+
// as a follow-up turn (cross-process steer is follow-up semantics, not mid-turn injection —
|
|
165
|
+
// spec B4). NOT fenced: this is the supervisor speaking, the same as the in-process
|
|
166
|
+
// engine's `onSteerable`, which injects steer text unfenced.
|
|
167
|
+
client.onSteer((text) => {
|
|
168
|
+
const trimmed = text.trim();
|
|
169
|
+
if (!trimmed) return;
|
|
170
|
+
sendFollowUp(pi, `[steer from your supervisor]\n${trimmed}`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// A live connection that drops (host teardown/crash) must not keep reporting "delivered":
|
|
174
|
+
// flip the bus offline so progress says "dropped" and asks fail fast as "unavailable".
|
|
175
|
+
client.onClose(() => {
|
|
176
|
+
bus.connected = false;
|
|
177
|
+
try {
|
|
178
|
+
ctx.ui.setStatus("persona-bridge", "⇄ offline");
|
|
179
|
+
} catch {
|
|
180
|
+
/* cosmetic */
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
bridgeReady = client.register().then(
|
|
185
|
+
() => {
|
|
186
|
+
bus.connected = true;
|
|
187
|
+
refreshPeers();
|
|
188
|
+
try {
|
|
189
|
+
ctx.ui.setStatus("persona-bridge", `⇄ ${handle}`);
|
|
190
|
+
} catch {
|
|
191
|
+
/* cosmetic */
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
() => {
|
|
195
|
+
// Capped retries exhausted (host gone, or a race on a just-spawned child) — degrade to
|
|
196
|
+
// a mute executor and keep running; never crash, never hang (spec §4).
|
|
197
|
+
if (env.PI_PERSONA_DEBUG) process.stderr.write(`[pi-persona] bridge: could not connect to ${endpoint}\n`);
|
|
198
|
+
try {
|
|
199
|
+
ctx.ui.setStatus("persona-bridge", "⇄ offline");
|
|
200
|
+
} catch {
|
|
201
|
+
/* cosmetic */
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
pi.on("session_shutdown", () => {
|
|
207
|
+
client.close();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broker client — the child-side half of the relay (spec B3/B6): connects to the
|
|
3
|
+
* session-scoped endpoint (`paths.ts`), performs the `register`/`registered` handshake,
|
|
4
|
+
* and exposes the same shape a local `contact_supervisor`/`contact_peer` binding needs —
|
|
5
|
+
* `send` (fire-and-forget), `ask` (a blocking `send{expectsReply}` correlated to a
|
|
6
|
+
* `replied{askId}`, 10-minute cap, mirrors `bus.ask`, and sends `cancel{msgId}` when
|
|
7
|
+
* aborted), `reply` (answers a host-issued
|
|
8
|
+
* blocking `deliver`), `list` (the engine-scoped peer roster, B7), and `onDeliver`/`onSteer`
|
|
9
|
+
* for inbound frames. A future bridge (`src/bridge.ts`) wires these onto Pi tools; this
|
|
10
|
+
* module has no Pi imports.
|
|
11
|
+
*
|
|
12
|
+
* Ported from comtac `broker/client.ts`, slimmed to the B6 frame set and to what a
|
|
13
|
+
* per-session child — which lives strictly less than the run — actually needs: capped
|
|
14
|
+
* exponential backoff (250ms–5s) on the INITIAL connect only (comtac's presence tracking,
|
|
15
|
+
* roster cache, and endless reconnect-after-disconnect "storm" are dropped — YAGNI here,
|
|
16
|
+
* see spec B6/Task 4 brief). An unexpected disconnect (host died
|
|
17
|
+
* mid-run) is NOT auto-reconnected: sends silently go nowhere, while in-flight asks/lists
|
|
18
|
+
* reject promptly and clean up their timers — the child keeps executing, mute, never crashing.
|
|
19
|
+
*
|
|
20
|
+
* Pure over an injected socket factory (`net`), mirroring `host.ts`'s `net` param — same
|
|
21
|
+
* transport-injection seam, opposite side of the same fake in tests.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import nodeNet from "node:net";
|
|
26
|
+
import type net from "node:net";
|
|
27
|
+
|
|
28
|
+
import { createFrameReader, encodeFrame } from "./framing.ts";
|
|
29
|
+
import { type Frame, isFrame } from "./messages.ts";
|
|
30
|
+
import type { MsgKind } from "../inproc.ts";
|
|
31
|
+
|
|
32
|
+
export interface DeliverEvent {
|
|
33
|
+
from: string;
|
|
34
|
+
fromLabel?: string;
|
|
35
|
+
kind: MsgKind;
|
|
36
|
+
text: string;
|
|
37
|
+
msgId: string;
|
|
38
|
+
expectsReply: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BrokerClient {
|
|
42
|
+
/** Connect (capped backoff) and complete the register handshake. */
|
|
43
|
+
register(): Promise<void>;
|
|
44
|
+
/** Fire-and-forget; a dead connection silently drops it (spec: "sends report undelivered"). */
|
|
45
|
+
send(to: string, kind: MsgKind, text: string): void;
|
|
46
|
+
/** Blocking request; resolves on a matching `replied`, rejects on `signal` abort or the
|
|
47
|
+
* 10-minute cap (mirrors `bus.ask`'s default). */
|
|
48
|
+
ask(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<string>;
|
|
49
|
+
/** Answers a host-issued blocking `deliver` (the supervisor asked THIS child). */
|
|
50
|
+
reply(askId: string, text: string): void;
|
|
51
|
+
list(): Promise<Array<{ handle: string; label: string }>>;
|
|
52
|
+
onDeliver(cb: (evt: DeliverEvent) => void): void;
|
|
53
|
+
onSteer(cb: (text: string) => void): void;
|
|
54
|
+
/** Fired once when a live connection drops (peer-initiated); not fired by close(). */
|
|
55
|
+
onClose(cb: () => void): void;
|
|
56
|
+
/** Sends `bye` (best-effort) and disposes the socket; idempotent. */
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MakeBrokerClientDeps {
|
|
61
|
+
endpoint: string;
|
|
62
|
+
handle: string;
|
|
63
|
+
label?: string;
|
|
64
|
+
group?: string;
|
|
65
|
+
peers?: boolean;
|
|
66
|
+
/** Injected transport (tests use a paired in-memory fake); defaults to real `node:net`. */
|
|
67
|
+
net?: typeof import("node:net");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const RECONNECT_BASE_MS = 250;
|
|
71
|
+
const RECONNECT_MAX_MS = 5000;
|
|
72
|
+
const CONNECT_MAX_ATTEMPTS = 6;
|
|
73
|
+
const ASK_TIMEOUT_MS = 600_000;
|
|
74
|
+
// A roster lookup is one local socket/pipe round-trip, unlike a blocking supervisor ask. Do not
|
|
75
|
+
// let a half-open broker pin `contact_peer list/send` until the child idle watchdog fires.
|
|
76
|
+
const LIST_TIMEOUT_MS = 10_000;
|
|
77
|
+
|
|
78
|
+
function delay(ms: number): Promise<void> {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const t = setTimeout(resolve, ms);
|
|
81
|
+
t.unref?.();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function makeBrokerClient(deps: MakeBrokerClientDeps): BrokerClient {
|
|
86
|
+
const netImpl = deps.net ?? nodeNet;
|
|
87
|
+
|
|
88
|
+
let socket: net.Socket | undefined;
|
|
89
|
+
let closed = false;
|
|
90
|
+
let disconnected = false;
|
|
91
|
+
let registerSettle: { resolve: () => void; reject: (err: Error) => void } | undefined;
|
|
92
|
+
|
|
93
|
+
const deliverListeners = new Set<(evt: DeliverEvent) => void>();
|
|
94
|
+
const steerListeners = new Set<(text: string) => void>();
|
|
95
|
+
const closeListeners = new Set<() => void>();
|
|
96
|
+
const pendingAsks = new Map<string, { resolve: (text: string) => void; reject: (err: Error) => void }>();
|
|
97
|
+
const pendingLists = new Map<string, { resolve: (peers: Array<{ handle: string; label: string }>) => void; reject: (err: Error) => void }>();
|
|
98
|
+
|
|
99
|
+
/** Best-effort write: a frame sent while disconnected is silently dropped — matches
|
|
100
|
+
* the design's "sends report undelivered" (no throw the caller could even act on,
|
|
101
|
+
* `send`/`reply` are `void`). */
|
|
102
|
+
function write(frame: Frame): void {
|
|
103
|
+
if (!socket) return;
|
|
104
|
+
try {
|
|
105
|
+
socket.write(encodeFrame(frame));
|
|
106
|
+
} catch {
|
|
107
|
+
/* peer went away mid-write */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** An application listener's throw must never reach the frame reader: the reader would
|
|
112
|
+
* poison itself and destroy the socket, muting the child for the rest of the run (the
|
|
113
|
+
* host hardens its own dispatch the same way). */
|
|
114
|
+
function safely(fn: () => void): void {
|
|
115
|
+
try {
|
|
116
|
+
fn();
|
|
117
|
+
} catch {
|
|
118
|
+
/* a faulty listener is the listener's problem, not the connection's */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function handleFrame(frame: Frame): void {
|
|
123
|
+
switch (frame.t) {
|
|
124
|
+
case "registered":
|
|
125
|
+
registerSettle?.resolve();
|
|
126
|
+
registerSettle = undefined;
|
|
127
|
+
return;
|
|
128
|
+
case "error":
|
|
129
|
+
// A correlated error belongs to one request on this connection. Do not let an
|
|
130
|
+
// unknown/stale correlation id affect the register handshake or another ask.
|
|
131
|
+
if (frame.msgId !== undefined) {
|
|
132
|
+
pendingAsks.get(frame.msgId)?.reject(new Error(frame.reason));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// A bare error is reserved for the register handshake. Older hosts emit this
|
|
136
|
+
// shape, so keep accepting it for protocol compatibility.
|
|
137
|
+
registerSettle?.reject(new Error(frame.reason));
|
|
138
|
+
registerSettle = undefined;
|
|
139
|
+
return;
|
|
140
|
+
case "deliver": {
|
|
141
|
+
const evt: DeliverEvent = {
|
|
142
|
+
from: frame.from,
|
|
143
|
+
kind: frame.kind,
|
|
144
|
+
text: frame.text,
|
|
145
|
+
msgId: frame.msgId,
|
|
146
|
+
expectsReply: frame.expectsReply,
|
|
147
|
+
...(frame.fromLabel !== undefined ? { fromLabel: frame.fromLabel } : {}),
|
|
148
|
+
};
|
|
149
|
+
for (const cb of deliverListeners) safely(() => cb(evt));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
case "steer":
|
|
153
|
+
for (const cb of steerListeners) safely(() => cb(frame.text));
|
|
154
|
+
return;
|
|
155
|
+
case "replied": {
|
|
156
|
+
const pending = pendingAsks.get(frame.askId);
|
|
157
|
+
if (pending) pending.resolve(frame.text);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "peers": {
|
|
161
|
+
const pending = pendingLists.get(frame.reqId);
|
|
162
|
+
if (pending) pending.resolve(frame.peers);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
return; // register/send/reply/list/bye are client→host-only; ignore if echoed
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function connectOnce(): Promise<net.Socket> {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const s = netImpl.connect(deps.endpoint);
|
|
173
|
+
let settled = false;
|
|
174
|
+
const onConnect = (): void => {
|
|
175
|
+
if (settled) return;
|
|
176
|
+
settled = true;
|
|
177
|
+
s.removeListener("error", onError);
|
|
178
|
+
resolve(s);
|
|
179
|
+
};
|
|
180
|
+
const onError = (err: Error): void => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
s.removeListener("connect", onConnect);
|
|
184
|
+
s.destroy();
|
|
185
|
+
reject(err);
|
|
186
|
+
};
|
|
187
|
+
s.once("connect", onConnect);
|
|
188
|
+
s.once("error", onError);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Capped exponential backoff (250ms → 5s) on the INITIAL connect only — comtac's
|
|
193
|
+
* endless reconnect-after-disconnect loop is deliberately not ported (see header). */
|
|
194
|
+
async function connectWithBackoff(): Promise<net.Socket> {
|
|
195
|
+
let lastErr: unknown;
|
|
196
|
+
for (let attempt = 0; attempt < CONNECT_MAX_ATTEMPTS; attempt += 1) {
|
|
197
|
+
if (closed) throw new Error("broker client closed");
|
|
198
|
+
try {
|
|
199
|
+
return await connectOnce();
|
|
200
|
+
} catch (err) {
|
|
201
|
+
lastErr = err;
|
|
202
|
+
if (attempt < CONNECT_MAX_ATTEMPTS - 1) {
|
|
203
|
+
await delay(Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rejectAllPending(reason: string): void {
|
|
211
|
+
for (const p of pendingAsks.values()) p.reject(new Error(reason));
|
|
212
|
+
pendingAsks.clear();
|
|
213
|
+
for (const p of pendingLists.values()) p.reject(new Error(reason));
|
|
214
|
+
pendingLists.clear();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function register(): Promise<void> {
|
|
218
|
+
const s = await connectWithBackoff();
|
|
219
|
+
if (closed) {
|
|
220
|
+
s.destroy();
|
|
221
|
+
throw new Error("broker client closed");
|
|
222
|
+
}
|
|
223
|
+
socket = s;
|
|
224
|
+
s.on(
|
|
225
|
+
"data",
|
|
226
|
+
createFrameReader(
|
|
227
|
+
(raw) => {
|
|
228
|
+
if (isFrame(raw)) handleFrame(raw);
|
|
229
|
+
},
|
|
230
|
+
() => s.destroy(),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
s.on("error", () => {
|
|
234
|
+
/* The close event rejects in-flight requests; this listener only prevents an
|
|
235
|
+
* unhandled socket error from taking down the child. */
|
|
236
|
+
});
|
|
237
|
+
s.once("close", () => {
|
|
238
|
+
socket = undefined;
|
|
239
|
+
// A peer-initiated drop (host teardown/crash) between connect and the handshake
|
|
240
|
+
// reply would otherwise leave register() pending for the child's whole life.
|
|
241
|
+
registerSettle?.reject(new Error("connection closed during register"));
|
|
242
|
+
registerSettle = undefined;
|
|
243
|
+
if (!closed) {
|
|
244
|
+
rejectAllPending("broker connection closed");
|
|
245
|
+
disconnected = true;
|
|
246
|
+
for (const cb of closeListeners) safely(cb);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return new Promise<void>((resolve, reject) => {
|
|
251
|
+
registerSettle = { resolve, reject };
|
|
252
|
+
write({
|
|
253
|
+
t: "register",
|
|
254
|
+
handle: deps.handle,
|
|
255
|
+
...(deps.label !== undefined ? { label: deps.label } : {}),
|
|
256
|
+
...(deps.group !== undefined ? { group: deps.group } : {}),
|
|
257
|
+
...(deps.peers !== undefined ? { peers: deps.peers } : {}),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function send(to: string, kind: MsgKind, text: string): void {
|
|
263
|
+
write({ t: "send", to, kind, text, msgId: randomUUID(), expectsReply: false });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Why `ask`/`list` refuse to send right now — distinguishes a disposed client from one that
|
|
267
|
+
* merely lost its connection (an unexpected disconnect is never auto-reconnected; see header). */
|
|
268
|
+
function unavailableReason(): string {
|
|
269
|
+
return closed ? "broker client closed" : "broker connection closed";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function ask(to: string, kind: MsgKind, text: string, signal?: AbortSignal): Promise<string> {
|
|
273
|
+
if (closed || disconnected) return Promise.reject(new Error(unavailableReason()));
|
|
274
|
+
const msgId = randomUUID();
|
|
275
|
+
return new Promise<string>((resolve, reject) => {
|
|
276
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
277
|
+
const onAbort = (): void => {
|
|
278
|
+
const wasPending = pendingAsks.has(msgId);
|
|
279
|
+
finish();
|
|
280
|
+
if (wasPending) write({ t: "cancel", msgId });
|
|
281
|
+
reject(new Error("ask aborted"));
|
|
282
|
+
};
|
|
283
|
+
// Always drop the pending entry + timer + abort listener, so a settled ask never
|
|
284
|
+
// leaks a 10-minute timer or a stale resolver (mirrors `bus.ask`'s `finish`).
|
|
285
|
+
const finish = (): void => {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
pendingAsks.delete(msgId);
|
|
288
|
+
signal?.removeEventListener("abort", onAbort);
|
|
289
|
+
};
|
|
290
|
+
timer = setTimeout(() => {
|
|
291
|
+
const wasPending = pendingAsks.has(msgId);
|
|
292
|
+
finish();
|
|
293
|
+
if (wasPending) write({ t: "cancel", msgId });
|
|
294
|
+
reject(new Error(`ask timeout after ${ASK_TIMEOUT_MS}ms`));
|
|
295
|
+
}, ASK_TIMEOUT_MS);
|
|
296
|
+
timer.unref?.();
|
|
297
|
+
if (signal) {
|
|
298
|
+
if (signal.aborted) {
|
|
299
|
+
finish();
|
|
300
|
+
reject(new Error("ask aborted"));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
304
|
+
}
|
|
305
|
+
pendingAsks.set(msgId, {
|
|
306
|
+
resolve: (text: string) => {
|
|
307
|
+
finish();
|
|
308
|
+
resolve(text);
|
|
309
|
+
},
|
|
310
|
+
reject: (err: Error) => {
|
|
311
|
+
finish();
|
|
312
|
+
reject(err);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
write({ t: "send", to, kind, text, msgId, expectsReply: true });
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function reply(askId: string, text: string): void {
|
|
320
|
+
write({ t: "reply", askId, text });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function list(): Promise<Array<{ handle: string; label: string }>> {
|
|
324
|
+
if (closed || disconnected) return Promise.reject(new Error(unavailableReason()));
|
|
325
|
+
const reqId = randomUUID();
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
328
|
+
const finish = (): void => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
pendingLists.delete(reqId);
|
|
331
|
+
};
|
|
332
|
+
pendingLists.set(reqId, {
|
|
333
|
+
resolve: (peers) => { finish(); resolve(peers); },
|
|
334
|
+
reject: (err) => { finish(); reject(err); },
|
|
335
|
+
});
|
|
336
|
+
timer = setTimeout(() => {
|
|
337
|
+
finish();
|
|
338
|
+
reject(new Error(`list timeout after ${LIST_TIMEOUT_MS}ms`));
|
|
339
|
+
}, LIST_TIMEOUT_MS);
|
|
340
|
+
timer.unref?.();
|
|
341
|
+
write({ t: "list", reqId });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onDeliver(cb: (evt: DeliverEvent) => void): void {
|
|
346
|
+
deliverListeners.add(cb);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function onSteer(cb: (text: string) => void): void {
|
|
350
|
+
steerListeners.add(cb);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function onClose(cb: () => void): void {
|
|
354
|
+
closeListeners.add(cb);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function close(): void {
|
|
358
|
+
if (closed) return;
|
|
359
|
+
closed = true;
|
|
360
|
+
const s = socket;
|
|
361
|
+
if (s) {
|
|
362
|
+
write({ t: "bye" });
|
|
363
|
+
// Capture + clear before end()/destroy(): a `close` event can fire
|
|
364
|
+
// SYNCHRONOUSLY (real sockets are async, but a fake — or a socket that is
|
|
365
|
+
// already half-closed — is not), which would otherwise null `socket` via the
|
|
366
|
+
// `once("close", …)` listener mid-call.
|
|
367
|
+
socket = undefined;
|
|
368
|
+
s.end();
|
|
369
|
+
s.destroy();
|
|
370
|
+
}
|
|
371
|
+
// A `register()` still mid-flight (connecting, or connected but awaiting
|
|
372
|
+
// `registered`/`error`) would otherwise hang forever — settle it too.
|
|
373
|
+
registerSettle?.reject(new Error("broker client closed"));
|
|
374
|
+
registerSettle = undefined;
|
|
375
|
+
rejectAllPending("broker client closed");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { register, send, ask, reply, list, onDeliver, onSteer, onClose, close };
|
|
379
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed JSON framing (ported from pi-subagents-comtac): a 4-byte big-endian
|
|
3
|
+
* length header + a UTF-8 JSON payload. A reader accumulates chunks, emits complete frames
|
|
4
|
+
* greedily, caps a single frame at 16 MiB (DoS guard), and POISONS itself on the first
|
|
5
|
+
* error (oversize / bad JSON) — the caller destroys the socket. Pure, identical on all OSes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export function encodeFrame(obj: unknown): Buffer {
|
|
11
|
+
const body = Buffer.from(JSON.stringify(obj), "utf8");
|
|
12
|
+
const head = Buffer.alloc(4);
|
|
13
|
+
head.writeUInt32BE(body.length, 0);
|
|
14
|
+
return Buffer.concat([head, body]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createFrameReader(onFrame: (obj: unknown) => void, onError: (e: Error) => void): (chunk: Buffer) => void {
|
|
18
|
+
let buf: Buffer = Buffer.alloc(0);
|
|
19
|
+
let poisoned = false;
|
|
20
|
+
return (chunk: Buffer): void => {
|
|
21
|
+
if (poisoned) return;
|
|
22
|
+
buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
|
|
23
|
+
for (;;) {
|
|
24
|
+
if (buf.length < 4) return;
|
|
25
|
+
const len = buf.readUInt32BE(0);
|
|
26
|
+
if (len > MAX_FRAME_BYTES) {
|
|
27
|
+
poisoned = true;
|
|
28
|
+
onError(new Error(`broker frame too large (${len} bytes)`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (buf.length < 4 + len) return;
|
|
32
|
+
const body = buf.subarray(4, 4 + len);
|
|
33
|
+
buf = buf.subarray(4 + len);
|
|
34
|
+
try {
|
|
35
|
+
onFrame(JSON.parse(body.toString("utf8")));
|
|
36
|
+
} catch (e) {
|
|
37
|
+
poisoned = true;
|
|
38
|
+
onError(e instanceof Error ? e : new Error(String(e)));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|