channels.tools 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { homedir } from "node:os";
2
+ import { loadChannelConfig } from "./config.ts";
3
+ import type { ChannelServerDef } from "./config.ts";
4
+ import { ConnectionManager } from "./connection.ts";
5
+ import type { Connection } from "./connection.ts";
6
+ import { createWake } from "./wake.ts";
7
+ import { applyIdentity } from "./identity.ts";
8
+ import { normalizeSchema, resolveToolNames, toTextContent } from "./tools.ts";
9
+
10
+ type Deps = {
11
+ env?: NodeJS.ProcessEnv;
12
+ loadConfig?: (cwd: string) => Record<string, ChannelServerDef>;
13
+ onBeforeSpawn?: () => void;
14
+ // Test seams only: override the ConnectionManager's retry backoff so tests
15
+ // can exercise the retry path without waiting out the real 30s/5min timers.
16
+ retryBaseMs?: number;
17
+ retryMaxMs?: number;
18
+ };
19
+
20
+ export function createExtension(pi: any, deps: Deps = {}): void {
21
+ const env = deps.env ?? process.env;
22
+ const loadConfig = deps.loadConfig ?? ((cwd: string) => loadChannelConfig({ home: homedir(), cwd }));
23
+
24
+ const manager = new ConnectionManager({
25
+ onEvent: (event) => wake.onEvent(event),
26
+ onStatus: (status) => uiSetStatus(status),
27
+ log: (message) => log(message),
28
+ // A channel that connects via retry — 30s+ after session_start already
29
+ // ran registerTools() once — is otherwise never brought back into the
30
+ // tool lifecycle: its instructions get injected and its events wake the
31
+ // agent, but the tools it just told the agent to call were never
32
+ // registered. registerTools() is idempotent (it clears and rebuilds
33
+ // registeredNames every call), so re-running it here is safe.
34
+ onConnected: (connections) => {
35
+ try {
36
+ registerTools(connections);
37
+ } catch (error) {
38
+ log(`re-registering tools after a retry reconnect failed: ${String(error)}`);
39
+ }
40
+ },
41
+ ...(deps.retryBaseMs !== undefined ? { retryBaseMs: deps.retryBaseMs } : {}),
42
+ ...(deps.retryMaxMs !== undefined ? { retryMaxMs: deps.retryMaxMs } : {}),
43
+ });
44
+
45
+ const wake = createWake({
46
+ deliver: (text) => {
47
+ // sendUserMessage, not sendMessage({triggerTurn}): the custom-message
48
+ // trigger path starts the turn without emitting before_agent_start
49
+ // (pi core skips it), so extensions that assemble or capture the system
50
+ // prompt there never see the turn — a bridge provider can hard-fail a
51
+ // session whose first turn is a channel wake. sendUserMessage runs the
52
+ // full prompt pipeline when idle and queues a steer while streaming.
53
+ pi.sendUserMessage(text, { deliverAs: "steer" });
54
+ },
55
+ log: (message) => log(message),
56
+ });
57
+
58
+ let ui: { setStatus?: (key: string, value?: string) => void } | undefined;
59
+ const registeredNames = new Set<string>();
60
+
61
+ function uiSetStatus(status: string): void {
62
+ ui?.setStatus?.("channels", status === "" ? undefined : status);
63
+ }
64
+
65
+ function log(message: string): void {
66
+ process.stderr.write(`[channels] ${message}\n`);
67
+ }
68
+
69
+ function registerTools(connections: Connection[]): void {
70
+ const builtins = (pi.getAllTools?.() ?? []).map((t: { name: string }) => t.name);
71
+ const { resolved, dropped } = resolveToolNames(
72
+ connections.map((c) => ({ name: c.name, tools: c.tools })),
73
+ builtins,
74
+ );
75
+ // A dropped tool is uncallable and invisible; say so rather than letting
76
+ // the agent wonder why a documented tool does not exist.
77
+ for (const drop of dropped) {
78
+ log(`channel ${drop.server}: tool "${drop.original}" not registered — ${drop.reason}`);
79
+ }
80
+
81
+ // session_start also fires for reload/resume/fork, each time with a
82
+ // freshly reconnected `connections` array — the previous connectAll()
83
+ // already closed the old children and clients. Re-registering
84
+ // unconditionally (rather than skipping names already seen) replaces
85
+ // each tool's `execute` closure so it captures the new connection;
86
+ // skipping would leave the tool calling a client that is already
87
+ // closed, failing silently on every future invocation.
88
+ const stillPresent = new Set(resolved.map((e) => e.registered));
89
+ // pi.unregisterTool does not exist in the installed pi 0.84.2 (verified
90
+ // against its docs/ and dist/) — the optional call below is a permanent
91
+ // no-op today, kept only for a future pi version that adds it. The
92
+ // fallback that actually works now: drop the stale names from the
93
+ // active set, computed from getActiveTools() minus what is stale, so a
94
+ // resume never leaves a tool pointing at a closed client. Both calls are
95
+ // guarded; nothing here may throw into session_start.
96
+ const stillStale: string[] = [];
97
+ for (const name of registeredNames) {
98
+ if (stillPresent.has(name)) continue;
99
+ let handled = false;
100
+ try {
101
+ handled = Boolean(pi.unregisterTool?.(name));
102
+ } catch (error) {
103
+ log(`failed to unregister stale tool "${name}": ${String(error)}`);
104
+ }
105
+ if (!handled) stillStale.push(name);
106
+ }
107
+ if (stillStale.length > 0) {
108
+ try {
109
+ const active: string[] = pi.getActiveTools?.() ?? [];
110
+ pi.setActiveTools?.(active.filter((name: string) => !stillStale.includes(name)));
111
+ } catch (error) {
112
+ log(`failed to drop stale tools from the active set: ${String(error)}`);
113
+ }
114
+ }
115
+ registeredNames.clear();
116
+
117
+ for (const entry of resolved) {
118
+ // entry.server always names a connection from `connections` — it was
119
+ // built from `connections.map(...)` above.
120
+ const conn = connections.find((c) => c.name === entry.server)!;
121
+ const def = conn.tools.find((t) => t.name === entry.original);
122
+ registeredNames.add(entry.registered);
123
+ pi.registerTool({
124
+ name: entry.registered,
125
+ label: entry.original,
126
+ description: def?.description ?? `Tool from the ${entry.server} channel`,
127
+ // Plain JSON Schema: pi's toolWireSchema and validateToolArguments
128
+ // treat it as such, and it keeps this module free of typebox.
129
+ parameters: normalizeSchema(def?.inputSchema),
130
+ // pi's documented signature is execute(toolCallId, params, signal,
131
+ // onUpdate, ctx). Threading `signal` through to the JSON-RPC request
132
+ // means an aborted turn rejects the pending call immediately instead
133
+ // of leaving it pending for the full 30s timeout.
134
+ async execute(_id: string, params: unknown, signal?: AbortSignal) {
135
+ // pi only honors a thrown error as tool failure (it sets
136
+ // isError: true on the reported result); returning any
137
+ // error-shaped value — nested or not — is inert and the agent
138
+ // reads it as a clean success.
139
+ let result: unknown;
140
+ try {
141
+ result = await conn.client.request(
142
+ "tools/call",
143
+ { name: entry.original, arguments: params ?? {} },
144
+ undefined,
145
+ signal,
146
+ );
147
+ } catch (error) {
148
+ throw new Error(`channel tool "${entry.original}" on ${entry.server} failed: ${String(error)}`);
149
+ }
150
+ const mapped = toTextContent(result);
151
+ if (mapped.isError) {
152
+ const text = mapped.content.map((c) => c.text).join("\n");
153
+ throw new Error(`channel tool "${entry.original}" on ${entry.server} reported an error: ${text}`);
154
+ }
155
+ return { content: mapped.content, details: { server: entry.server } };
156
+ },
157
+ });
158
+ }
159
+ }
160
+
161
+ pi.on("session_start", async (_event: unknown, ctx: any) => {
162
+ ui = ctx.ui;
163
+ wake.onSessionStart();
164
+
165
+ // The whole body runs under try/finally so wake.onReady() always fires,
166
+ // even if something in between throws (most plausibly pi.registerTool
167
+ // rejecting a schema shaped by an external, out-of-our-control server).
168
+ // onReady() is the only thing that opens the mail gate; skipping it on
169
+ // an unhandled error would silently swallow every channel event for the
170
+ // rest of the session's lifetime.
171
+ try {
172
+ // Ordering is load-bearing: servers may read AGENT_SESSION_ID at
173
+ // their own startup to scope what they attach to. Set it late and
174
+ // such a channel comes up blind while the connection still looks
175
+ // healthy.
176
+ applyIdentity(ctx.sessionManager?.getSessionId?.(), env);
177
+ deps.onBeforeSpawn?.();
178
+
179
+ const defs = loadConfig(ctx.cwd ?? process.cwd());
180
+ await manager.connectAll(defs);
181
+ // manager.connections(), NOT the array connectAll returned: a retry that
182
+ // reconnected while a sibling was still handshaking has already
183
+ // registered its tools via onConnected, and connectAll's array cannot
184
+ // contain that server. Registering from the narrower array treats those
185
+ // tools as stale and drops them — leaving a live channel whose
186
+ // instructions are injected and whose events wake the agent, with no
187
+ // tools and no further retry scheduled. connections() is a superset in
188
+ // every other case.
189
+ registerTools(manager.connections());
190
+ } catch (error) {
191
+ log(`session_start failed: ${String(error)}`);
192
+ } finally {
193
+ // The gate opens when session_start completes — never on first
194
+ // settle, since agent_settled only fires after an agent run.
195
+ wake.onReady();
196
+ }
197
+ });
198
+
199
+ pi.on("before_agent_start", async (event: { systemPrompt?: string }) => {
200
+ const blocks = manager
201
+ .connections()
202
+ .filter((c) => typeof c.instructions === "string" && c.instructions !== "")
203
+ .map((c) => `<channel-instructions source="${c.name}">\n${c.instructions}\n</channel-instructions>`);
204
+ if (blocks.length === 0) return undefined;
205
+ return { systemPrompt: `${event.systemPrompt ?? ""}\n\n${blocks.join("\n\n")}` };
206
+ });
207
+
208
+ pi.on("agent_start", () => wake.onAgentStart());
209
+ pi.on("agent_settled", () => wake.onAgentSettled());
210
+ pi.on("session_shutdown", async () => { await manager.closeAll(); });
211
+ }
212
+
213
+ export default function (pi: any): void {
214
+ createExtension(pi);
215
+ }
@@ -0,0 +1,109 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { normalizeSchema, resolveToolNames, toTextContent } from "./tools.ts";
4
+
5
+ test("normalizeSchema strips $schema and additionalProperties", () => {
6
+ const out = normalizeSchema({
7
+ $schema: "http://json-schema.org/draft-07/schema#",
8
+ type: "object",
9
+ properties: { doc: { type: "string" } },
10
+ additionalProperties: false,
11
+ });
12
+ assert.deepEqual(out, { type: "object", properties: { doc: { type: "string" } } });
13
+ });
14
+
15
+ test("normalizeSchema substitutes an empty object schema for junk", () => {
16
+ assert.deepEqual(normalizeSchema(undefined), { type: "object", properties: {} });
17
+ assert.deepEqual(normalizeSchema(null), { type: "object", properties: {} });
18
+ assert.deepEqual(normalizeSchema("nonsense"), { type: "object", properties: {} });
19
+ assert.deepEqual(normalizeSchema([1, 2]), { type: "object", properties: {} });
20
+ });
21
+
22
+ test("tool names are registered unprefixed so server instructions stay true", () => {
23
+ const { resolved, dropped } = resolveToolNames(
24
+ [{ name: "muster-channel", tools: [{ name: "muster_channel_status" }] }],
25
+ ["bash", "read"],
26
+ );
27
+ assert.deepEqual(resolved, [
28
+ { server: "muster-channel", original: "muster_channel_status", registered: "muster_channel_status" },
29
+ ]);
30
+ assert.deepEqual(dropped, []);
31
+ });
32
+
33
+ test("a collision with a builtin is prefixed with the server name", () => {
34
+ const { resolved } = resolveToolNames([{ name: "galley", tools: [{ name: "read" }] }], ["bash", "read"]);
35
+ assert.equal(resolved[0].registered, "galley_read");
36
+ assert.equal(resolved[0].original, "read");
37
+ });
38
+
39
+ test("a collision between two servers prefixes only the later one", () => {
40
+ const { resolved } = resolveToolNames(
41
+ [
42
+ { name: "alpha", tools: [{ name: "status" }] },
43
+ { name: "beta", tools: [{ name: "status" }] },
44
+ ],
45
+ [],
46
+ );
47
+ assert.equal(resolved[0].registered, "status");
48
+ assert.equal(resolved[1].registered, "beta_status");
49
+ });
50
+
51
+ test("server names are sanitized before prefixing", () => {
52
+ const { resolved } = resolveToolNames([{ name: "muster-channel", tools: [{ name: "read" }] }], ["read"]);
53
+ assert.equal(resolved[0].registered, "muster_channel_read");
54
+ });
55
+
56
+ test("a tool whose prefixed name still collides with a builtin is dropped with a reason naming the builtin", () => {
57
+ const { resolved, dropped } = resolveToolNames(
58
+ [{ name: "galley", tools: [{ name: "read" }] }],
59
+ ["read", "galley_read"],
60
+ );
61
+ assert.deepEqual(resolved, []);
62
+ assert.equal(dropped.length, 1);
63
+ assert.equal(dropped[0].server, "galley");
64
+ assert.equal(dropped[0].original, "read");
65
+ assert.match(dropped[0].reason, /builtin/);
66
+ });
67
+
68
+ test("a tool whose prefixed name collides with another server's registered tool is dropped with a reason naming a cross-server collision, not a builtin", () => {
69
+ // No builtins at all: gamma takes the bare name "x", beta gets prefixed to
70
+ // "beta_x" and registers it, then a second beta tool also named "x" tries
71
+ // the same bare-then-prefixed path and finds "beta_x" already taken by its
72
+ // own server's earlier tool — a collision with zero builtins involved.
73
+ const { resolved, dropped } = resolveToolNames(
74
+ [
75
+ { name: "gamma", tools: [{ name: "x" }] },
76
+ { name: "beta", tools: [{ name: "x" }, { name: "x" }] },
77
+ ],
78
+ [],
79
+ );
80
+ assert.equal(resolved.length, 2);
81
+ assert.equal(dropped.length, 1);
82
+ assert.equal(dropped[0].server, "beta");
83
+ assert.equal(dropped[0].original, "x");
84
+ assert.match(dropped[0].reason, /another server/);
85
+ assert.doesNotMatch(dropped[0].reason, /builtin/);
86
+ });
87
+
88
+ test("toTextContent maps MCP text blocks through", () => {
89
+ const out = toTextContent({ content: [{ type: "text", text: "hello" }] });
90
+ assert.deepEqual(out.content, [{ type: "text", text: "hello" }]);
91
+ assert.equal(out.isError, false);
92
+ });
93
+
94
+ test("toTextContent honors isError", () => {
95
+ const out = toTextContent({ content: [{ type: "text", text: "boom" }], isError: true });
96
+ assert.equal(out.isError, true);
97
+ });
98
+
99
+ test("toTextContent renders non-text blocks as a readable placeholder", () => {
100
+ const out = toTextContent({ content: [{ type: "image", data: "…", mimeType: "image/png" }] });
101
+ assert.equal(out.content.length, 1);
102
+ assert.match(out.content[0].text, /image/);
103
+ });
104
+
105
+ test("toTextContent survives a result with no content", () => {
106
+ const out = toTextContent({});
107
+ assert.equal(out.content.length, 1);
108
+ assert.equal(out.isError, false);
109
+ });
package/src/tools.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { McpToolDef } from "./connection.ts";
2
+
3
+ export function normalizeSchema(schema: unknown): Record<string, unknown> {
4
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
5
+ return { type: "object", properties: {} };
6
+ }
7
+ // $schema and additionalProperties are stripped because pi's validator
8
+ // treats the object as a typebox schema; real servers ship
9
+ // additionalProperties:false, which is the case this guards.
10
+ const { $schema: _s, additionalProperties: _a, ...rest } = schema as Record<string, unknown>;
11
+ return rest;
12
+ }
13
+
14
+ export function resolveToolNames(
15
+ connections: Array<{ name: string; tools: McpToolDef[] }>,
16
+ builtins: string[],
17
+ ): {
18
+ resolved: Array<{ server: string; original: string; registered: string }>;
19
+ dropped: Array<{ server: string; original: string; reason: string }>;
20
+ } {
21
+ const builtinSet = new Set(builtins);
22
+ const taken = new Set(builtins);
23
+ const resolved: Array<{ server: string; original: string; registered: string }> = [];
24
+ const dropped: Array<{ server: string; original: string; reason: string }> = [];
25
+ for (const conn of connections) {
26
+ for (const tool of conn.tools) {
27
+ // Unprefixed by default: channel servers already self-namespace, and
28
+ // their instructions name these tools literally. Prefix only on a real
29
+ // clash, so at most one server's documentation is ever wrong.
30
+ let registered = tool.name;
31
+ if (taken.has(registered)) registered = `${conn.name.replace(/[^A-Za-z0-9_]/g, "_")}_${tool.name}`;
32
+ if (taken.has(registered)) {
33
+ // Attribute the drop to whatever the *final* (possibly prefixed) name
34
+ // actually collided with — builtins never change, but `taken` grows
35
+ // as servers are processed, so this must be checked against the
36
+ // original builtin set, not the flag from the unprefixed check above.
37
+ const reason = builtinSet.has(registered)
38
+ ? `prefixed name "${registered}" still collides with a builtin tool`
39
+ : `prefixed name "${registered}" collides with another server's registered tool`;
40
+ dropped.push({ server: conn.name, original: tool.name, reason });
41
+ continue;
42
+ }
43
+ taken.add(registered);
44
+ resolved.push({ server: conn.name, original: tool.name, registered });
45
+ }
46
+ }
47
+ return { resolved, dropped };
48
+ }
49
+
50
+ export function toTextContent(result: unknown): {
51
+ content: Array<{ type: "text"; text: string }>;
52
+ isError: boolean;
53
+ } {
54
+ const r = (typeof result === "object" && result !== null ? result : {}) as {
55
+ content?: unknown;
56
+ isError?: unknown;
57
+ };
58
+ const blocks = Array.isArray(r.content) ? r.content : [];
59
+ const content = blocks.map((block) => {
60
+ const b = (typeof block === "object" && block !== null ? block : {}) as Record<string, unknown>;
61
+ if (b.type === "text" && typeof b.text === "string") return { type: "text" as const, text: b.text };
62
+ return { type: "text" as const, text: `[${String(b.type ?? "unknown")} content omitted]` };
63
+ });
64
+ if (content.length === 0) content.push({ type: "text" as const, text: "(no content)" });
65
+ return { content, isError: r.isError === true };
66
+ }
@@ -0,0 +1,121 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createWake } from "./wake.ts";
4
+ import type { ChannelEvent } from "./envelope.ts";
5
+
6
+ function harness() {
7
+ const delivered: string[] = [];
8
+ const wake = createWake({ deliver: (text) => delivered.push(text) });
9
+ wake.onSessionStart();
10
+ wake.onReady();
11
+ return { wake, delivered };
12
+ }
13
+
14
+ const ev = (source: string, content: string, meta: Record<string, string> = {}): ChannelEvent =>
15
+ ({ source, content, meta });
16
+
17
+ test("an event while idle delivers immediately", () => {
18
+ const { wake, delivered } = harness();
19
+ wake.onEvent(ev("muster-channel", "mail arrived"));
20
+ assert.equal(delivered.length, 1);
21
+ assert.match(delivered[0], /mail arrived/);
22
+ });
23
+
24
+ test("an event mid-turn is buffered, not delivered", () => {
25
+ const { wake, delivered } = harness();
26
+ wake.onAgentStart();
27
+ wake.onEvent(ev("muster-channel", "mail arrived"));
28
+ assert.equal(delivered.length, 0);
29
+ assert.equal(wake.pendingCount(), 1);
30
+ });
31
+
32
+ test("settling flushes the buffer coalesced into one delivery per source", () => {
33
+ const { wake, delivered } = harness();
34
+ wake.onAgentStart();
35
+ wake.onEvent(ev("muster-channel", "one"));
36
+ wake.onEvent(ev("muster-channel", "two"));
37
+ wake.onAgentSettled();
38
+ assert.equal(delivered.length, 1);
39
+ assert.match(delivered[0], /one/);
40
+ assert.match(delivered[0], /two/);
41
+ assert.equal(wake.pendingCount(), 0);
42
+ });
43
+
44
+ test("events from different sources flush as separate deliveries", () => {
45
+ const { wake, delivered } = harness();
46
+ wake.onAgentStart();
47
+ wake.onEvent(ev("muster-channel", "mail"));
48
+ wake.onEvent(ev("galley", "revise"));
49
+ wake.onAgentSettled();
50
+ assert.equal(delivered.length, 2);
51
+ assert.match(delivered[0], /source="muster-channel"/);
52
+ assert.match(delivered[1], /source="galley"/);
53
+ });
54
+
55
+ test("settling with an empty buffer delivers nothing", () => {
56
+ const { wake, delivered } = harness();
57
+ wake.onAgentStart();
58
+ wake.onAgentSettled();
59
+ assert.equal(delivered.length, 0);
60
+ });
61
+
62
+ test("mail arriving before ready is buffered and released once ready", () => {
63
+ const delivered: string[] = [];
64
+ const wake = createWake({ deliver: (text) => delivered.push(text) });
65
+ wake.onSessionStart();
66
+ wake.onEvent(ev("muster-channel", "early mail"));
67
+ assert.equal(delivered.length, 0, "must not deliver into a half-built session");
68
+ wake.onReady();
69
+ assert.equal(delivered.length, 1);
70
+ assert.match(delivered[0], /early mail/);
71
+ });
72
+
73
+ test("a turn started by the operator also buffers", () => {
74
+ const { wake, delivered } = harness();
75
+ wake.onAgentStart(); // not triggered by us
76
+ wake.onEvent(ev("muster-channel", "mail"));
77
+ assert.equal(delivered.length, 0);
78
+ });
79
+
80
+ test("session_start clears the buffer and closes the gate again", () => {
81
+ const { wake, delivered } = harness();
82
+ wake.onAgentStart();
83
+ wake.onEvent(ev("muster-channel", "stale"));
84
+ wake.onSessionStart();
85
+ assert.equal(wake.pendingCount(), 0);
86
+ wake.onEvent(ev("muster-channel", "after resume"));
87
+ assert.equal(delivered.length, 0, "gate is closed until ready");
88
+ wake.onReady();
89
+ assert.equal(delivered.length, 1);
90
+ assert.doesNotMatch(delivered[0], /stale/);
91
+ });
92
+
93
+ test("a throwing deliver for one source still delivers the other (F2)", () => {
94
+ const delivered: string[] = [];
95
+ const logs: string[] = [];
96
+ const wake = createWake({
97
+ deliver: (text) => {
98
+ if (text.includes('source="bad"')) throw new Error("boom: deliver exploded");
99
+ delivered.push(text);
100
+ },
101
+ log: (m) => logs.push(m),
102
+ });
103
+ wake.onSessionStart();
104
+ wake.onReady();
105
+ wake.onAgentStart();
106
+ wake.onEvent(ev("bad", "one"));
107
+ wake.onEvent(ev("good", "two"));
108
+ wake.onAgentSettled();
109
+ assert.equal(delivered.length, 1, "the good source's batch must still be delivered");
110
+ assert.match(delivered[0], /source="good"/);
111
+ assert.ok(logs.some((l) => /bad/.test(l)), `expected a log naming the failed source, got: ${logs.join(" | ")}`);
112
+ });
113
+
114
+ test("delivery order across sources follows first arrival", () => {
115
+ const { wake, delivered } = harness();
116
+ wake.onAgentStart();
117
+ wake.onEvent(ev("galley", "first"));
118
+ wake.onEvent(ev("muster-channel", "second"));
119
+ wake.onAgentSettled();
120
+ assert.match(delivered[0], /source="galley"/);
121
+ });
package/src/wake.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { renderBatch } from "./envelope.ts";
2
+ import type { ChannelEvent } from "./envelope.ts";
3
+
4
+ export type Wake = {
5
+ onEvent(event: ChannelEvent): void;
6
+ onAgentStart(): void;
7
+ onAgentSettled(): void;
8
+ onSessionStart(): void;
9
+ onReady(): void;
10
+ pendingCount(): number;
11
+ };
12
+
13
+ export function createWake(opts: { deliver: (text: string) => void; log?: (message: string) => void }): Wake {
14
+ // Insertion order of this Map is the delivery order across sources.
15
+ let buffer = new Map<string, ChannelEvent[]>();
16
+ let busy = false;
17
+ let ready = false;
18
+
19
+ function pendingCount(): number {
20
+ let n = 0;
21
+ for (const [, events] of buffer) n += events.length;
22
+ return n;
23
+ }
24
+
25
+ function flush(): void {
26
+ if (buffer.size === 0) return;
27
+ const batches = buffer;
28
+ buffer = new Map();
29
+ for (const [source, events] of batches) {
30
+ if (events.length === 0) continue;
31
+ try {
32
+ opts.deliver(renderBatch(source, events));
33
+ } catch (error) {
34
+ // This runs synchronously inside the OS stream callback chain
35
+ // (stdout 'data' -> dispatch -> onEvent -> flush -> deliver ->
36
+ // pi.sendUserMessage). A throw here must not escape: uncaught, it kills
37
+ // the pi process from inside an EventEmitter callback. And because
38
+ // `buffer` was already swapped out above, one bad delivery must not
39
+ // stop the loop — the next source's events would otherwise be
40
+ // discarded along with it, which is exactly the silent-mail-loss
41
+ // failure this component exists to prevent.
42
+ opts.log?.(`delivery to ${source} failed: ${String(error)}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ return {
48
+ onEvent(event) {
49
+ const events = buffer.get(event.source) ?? [];
50
+ events.push(event);
51
+ buffer.set(event.source, events);
52
+ // The gate must be open AND no turn running. `ready` is set when
53
+ // session_start finishes, never on first settle: agent_settled only
54
+ // fires after an agent run, so a session whose first event is inbound
55
+ // mail would otherwise never settle and never deliver.
56
+ if (ready && !busy) flush();
57
+ },
58
+ onAgentStart() {
59
+ busy = true;
60
+ },
61
+ onAgentSettled() {
62
+ busy = false;
63
+ if (ready) flush();
64
+ },
65
+ onSessionStart() {
66
+ busy = false;
67
+ ready = false;
68
+ buffer = new Map();
69
+ },
70
+ onReady() {
71
+ ready = true;
72
+ if (!busy) flush();
73
+ },
74
+ pendingCount,
75
+ };
76
+ }