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.
@@ -0,0 +1,299 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join } from "node:path";
4
+ import { ConnectionManager } from "./connection.ts";
5
+ import type { ChannelEvent } from "./envelope.ts";
6
+
7
+ const FAKE = join(import.meta.dirname, "..", "test", "fake-channel-server.ts");
8
+
9
+ function manager(overrides: { retryBaseMs?: number; retryMaxMs?: number } = {}) {
10
+ const events: ChannelEvent[] = [];
11
+ const statuses: string[] = [];
12
+ const logs: string[] = [];
13
+ const mgr = new ConnectionManager({
14
+ onEvent: (e) => events.push(e),
15
+ onStatus: (s) => statuses.push(s),
16
+ log: (m) => logs.push(m),
17
+ ...overrides,
18
+ });
19
+ return { mgr, events, statuses, logs };
20
+ }
21
+
22
+ test("connects a server declaring the channel capability and captures instructions and tools", async () => {
23
+ const { mgr } = manager();
24
+ const conns = await mgr.connectAll({ fake: { command: process.execPath, args: [FAKE] } });
25
+ assert.equal(conns.length, 1);
26
+ assert.equal(conns[0].name, "fake");
27
+ assert.equal(conns[0].instructions, "FAKE CORE INSTRUCTIONS");
28
+ assert.equal(conns[0].tools.length, 1);
29
+ assert.equal(conns[0].tools[0].name, "fake_status");
30
+ await mgr.closeAll();
31
+ });
32
+
33
+ test("rejects a server that does not declare the capability", async () => {
34
+ const { mgr, logs } = manager();
35
+ const conns = await mgr.connectAll({
36
+ nope: { command: process.execPath, args: [FAKE], env: { FAKE_NO_CAPABILITY: "1" } },
37
+ });
38
+ assert.equal(conns.length, 0);
39
+ assert.ok(logs.some((l) => /capability/i.test(l)), `expected a capability log, got: ${logs.join(" | ")}`);
40
+ // Capability rejection is a permanent incompatibility, not a transient
41
+ // fault — it must never be retried.
42
+ assert.ok(!logs.some((l) => /retry|retrying/i.test(l)), `expected no retry log, got: ${logs.join(" | ")}`);
43
+ await mgr.closeAll();
44
+ });
45
+
46
+ test("a missing binary is reported, not thrown, and is retried", async () => {
47
+ const { mgr, logs } = manager();
48
+ const conns = await mgr.connectAll({ ghost: { command: "definitely-not-a-real-binary-xyz" } });
49
+ assert.equal(conns.length, 0);
50
+ assert.ok(logs.some((l) => /ghost/.test(l)));
51
+ assert.ok(logs.some((l) => /retry|retrying/i.test(l)), `expected a retry log, got: ${logs.join(" | ")}`);
52
+ await mgr.closeAll();
53
+ });
54
+
55
+ test("an inbound channel notification reaches onEvent tagged with its source", async () => {
56
+ const { mgr, events } = manager();
57
+ await mgr.connectAll({
58
+ fake: { command: process.execPath, args: [FAKE], env: { FAKE_PUSH_AFTER_MS: "50" } },
59
+ });
60
+ await new Promise((r) => setTimeout(r, 400));
61
+ assert.equal(events.length, 1);
62
+ assert.equal(events[0].source, "fake");
63
+ assert.match(events[0].content, /something happened/);
64
+ assert.equal(events[0].meta.intent, "fyi");
65
+ await mgr.closeAll();
66
+ });
67
+
68
+ test("notifications other than the channel notification are ignored", async () => {
69
+ const { mgr, events } = manager();
70
+ const conns = await mgr.connectAll({ fake: { command: process.execPath, args: [FAKE] } });
71
+ conns[0].client.notify("notifications/unrelated", { x: 1 });
72
+ await new Promise((r) => setTimeout(r, 100));
73
+ assert.equal(events.length, 0);
74
+ await mgr.closeAll();
75
+ });
76
+
77
+ test("closeAll is idempotent and leaves no connections", async () => {
78
+ const { mgr } = manager();
79
+ await mgr.connectAll({ fake: { command: process.execPath, args: [FAKE] } });
80
+ await mgr.closeAll();
81
+ await mgr.closeAll();
82
+ assert.equal(mgr.connections().length, 0);
83
+ });
84
+
85
+ test("connectAll after a previous connect replaces rather than duplicates", async () => {
86
+ const { mgr } = manager();
87
+ await mgr.connectAll({ fake: { command: process.execPath, args: [FAKE] } });
88
+ await mgr.connectAll({ fake: { command: process.execPath, args: [FAKE] } });
89
+ assert.equal(mgr.connections().length, 1);
90
+ await mgr.closeAll();
91
+ });
92
+
93
+ test("a child that dies mid-handshake is not accepted, reports unhealthy, is retried, and connectAll returns promptly", async () => {
94
+ const { mgr, logs, statuses } = manager();
95
+ const start = Date.now();
96
+ // This process spawns successfully but exits immediately without ever
97
+ // reading stdin or writing a response — a deterministic way to die before
98
+ // `initialize` can complete, without racing a timer against a fixture.
99
+ const conns = await mgr.connectAll({
100
+ dead: { command: process.execPath, args: ["-e", "process.exit(1)"] },
101
+ });
102
+ const elapsed = Date.now() - start;
103
+ assert.equal(conns.length, 0);
104
+ assert.equal(mgr.connections().length, 0);
105
+ assert.ok(logs.some((l) => /handshake/i.test(l)), `expected a mid-handshake log, got: ${logs.join(" | ")}`);
106
+ assert.ok(logs.some((l) => /retry|retrying/i.test(l)), `expected a retry log, got: ${logs.join(" | ")}`);
107
+ assert.ok(statuses.some((s) => /DEGRADED/.test(s)), `expected a DEGRADED status, got: ${statuses.join(" | ")}`);
108
+ assert.ok(elapsed < 5000, `connectAll took ${elapsed}ms — it must not stall on the JSON-RPC 30s timeout`);
109
+ await mgr.closeAll();
110
+ });
111
+
112
+ test("a channel that dies after connecting is marked degraded and a retry is scheduled", async () => {
113
+ const { mgr, logs, statuses } = manager();
114
+ const conns = await mgr.connectAll({
115
+ fake: { command: process.execPath, args: [FAKE], env: { FAKE_EXIT_AFTER_MS: "50" } },
116
+ });
117
+ assert.equal(conns.length, 1);
118
+ await new Promise((r) => setTimeout(r, 300));
119
+ assert.equal(mgr.connections().length, 0);
120
+ assert.ok(statuses.some((s) => /DEGRADED/.test(s)), `expected a DEGRADED status, got: ${statuses.join(" | ")}`);
121
+ assert.ok(logs.some((l) => /retrying/i.test(l)), `expected a retry log, got: ${logs.join(" | ")}`);
122
+ await mgr.closeAll();
123
+ });
124
+
125
+ test("closeAll while a connect is mid-handshake kills the pending child promptly and it never becomes live", async () => {
126
+ const { mgr } = manager();
127
+ // Never responds to initialize and never exits on its own — the only way
128
+ // this settles is if closeAll() actively kills it.
129
+ const connectPromise = mgr.connectAll({
130
+ hang: { command: process.execPath, args: ["-e", "setTimeout(() => {}, 60000)"] },
131
+ });
132
+ await new Promise((r) => setTimeout(r, 150));
133
+ const start = Date.now();
134
+ await mgr.closeAll();
135
+ const conns = await connectPromise;
136
+ const elapsed = Date.now() - start;
137
+ assert.equal(conns.length, 0);
138
+ assert.equal(mgr.connections().length, 0);
139
+ assert.ok(elapsed < 5000, `closeAll + settle took ${elapsed}ms — the pending child must be killed, not waited out`);
140
+ });
141
+
142
+ function isAlive(pid: number): boolean {
143
+ try {
144
+ process.kill(pid, 0);
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
151
+ test("closeAll kills a child that ignores SIGTERM, and it exits promptly", async () => {
152
+ const { mgr, logs } = manager();
153
+ // A real muster channel process ignores SIGTERM outright — only SIGKILL
154
+ // ends it. Without the escalation in killChild(), this child (and the
155
+ // real one) survives closeAll() as an orphan, holding its stdio pipes
156
+ // open and hanging the process that spawned it.
157
+ await mgr.connectAll({
158
+ stubborn: { command: process.execPath, args: [FAKE], env: { FAKE_IGNORE_SIGTERM: "1" } },
159
+ });
160
+ const pidLine = logs.find((l) => l.includes("PID:"));
161
+ assert.ok(pidLine, `expected the fake server to report its pid, got: ${logs.join(" | ")}`);
162
+ const pid = Number(pidLine.split("PID:")[1]);
163
+ assert.ok(isAlive(pid), `expected pid ${pid} to be alive before closeAll()`);
164
+
165
+ const start = Date.now();
166
+ await mgr.closeAll();
167
+ while (isAlive(pid)) {
168
+ assert.ok(Date.now() - start < 2000, `pid ${pid} is still alive ${Date.now() - start}ms after closeAll()`);
169
+ await new Promise((r) => setTimeout(r, 20));
170
+ }
171
+ });
172
+
173
+ test("connections() is ordered by configured server order, not handshake completion order (F4)", async () => {
174
+ const { mgr } = manager();
175
+ // "slow" is listed first in the config but will finish its handshake
176
+ // later — a fake server has no artificial handshake delay knob, so this
177
+ // relies on "fast" being pushed a channel event to prove genuine liveness,
178
+ // while ordering itself is asserted purely from `defs` key order.
179
+ await mgr.connectAll({
180
+ zeta: { command: process.execPath, args: [FAKE] },
181
+ alpha: { command: process.execPath, args: [FAKE] },
182
+ mid: { command: process.execPath, args: [FAKE] },
183
+ });
184
+ const names = mgr.connections().map((c) => c.name);
185
+ assert.deepEqual(names, ["zeta", "alpha", "mid"], `expected configured order, got: ${names.join(", ")}`);
186
+ await mgr.closeAll();
187
+ });
188
+
189
+ test("a live stream error is logged rather than silently swallowed (F3)", async () => {
190
+ const { mgr, logs } = manager();
191
+ const conns = await mgr.connectAll({ fake: { command: process.execPath, args: [FAKE] } });
192
+ const entry = (mgr as unknown as { live: Map<string, { child: { stdout: NodeJS.ReadableStream } }> }).live.get(
193
+ "fake",
194
+ );
195
+ assert.ok(entry, "fake must be live");
196
+ entry!.child.stdout.emit("error", new Error("EPIPE synthetic"));
197
+ assert.ok(
198
+ logs.some((l) => /stdout error/i.test(l) && /EPIPE synthetic/.test(l)),
199
+ `expected a logged stdout error, got: ${logs.join(" | ")}`,
200
+ );
201
+ assert.equal(conns.length, 1);
202
+ await mgr.closeAll();
203
+ });
204
+
205
+ test("a retry that reconnects fires onConnected with the up-to-date connection list (F1)", async () => {
206
+ const events: ChannelEvent[] = [];
207
+ const statuses: string[] = [];
208
+ const logs: string[] = [];
209
+ const onConnectedCalls: string[][] = [];
210
+ const mgr = new ConnectionManager({
211
+ onEvent: (e) => events.push(e),
212
+ onStatus: (s) => statuses.push(s),
213
+ log: (m) => logs.push(m),
214
+ onConnected: (conns) => onConnectedCalls.push(conns.map((c) => c.name)),
215
+ retryBaseMs: 40,
216
+ retryMaxMs: 40,
217
+ });
218
+ // A process that spawns and exits immediately without responding to
219
+ // initialize: a deterministic mid-handshake death on the *first* attempt.
220
+ // The retry's own connectOne (same command/args) will hit the same fate
221
+ // every time with this fixture, so instead swap the def in place before
222
+ // the retry fires: scheduleRetry reads `this.defs[name]` fresh each time,
223
+ // so mutating the manager's stored def changes what the retry spawns.
224
+ const conns = await mgr.connectAll({ flaky: { command: process.execPath, args: ["-e", "process.exit(1)"] } });
225
+ assert.equal(conns.length, 0);
226
+ (mgr as unknown as { defs: Record<string, unknown> }).defs.flaky = { command: process.execPath, args: [FAKE] };
227
+ await new Promise((r) => setTimeout(r, 400));
228
+ assert.ok(onConnectedCalls.length > 0, "onConnected must fire once the retry connects");
229
+ assert.deepEqual(onConnectedCalls.at(-1), ["flaky"]);
230
+ assert.equal(mgr.connections().length, 1);
231
+ await mgr.closeAll();
232
+ });
233
+
234
+ test("an unexpected rejection from a retry's connectOne is caught, not left unhandled (F7)", async () => {
235
+ const { mgr, logs } = manager({ retryBaseMs: 40, retryMaxMs: 40 });
236
+ await mgr.connectAll({ ghost: { command: "definitely-not-a-real-binary-xyz" } });
237
+
238
+ // Force the retry's own connectOne to throw, simulating an unexpected
239
+ // failure inside handshake (e.g. child.kill() EPERM) rather than one of
240
+ // connectOne's normal resolve-to-undefined error paths.
241
+ const patched = mgr as unknown as { connectOne: (...args: unknown[]) => Promise<unknown> };
242
+ patched.connectOne = async () => {
243
+ throw new Error("synthetic handshake throw");
244
+ };
245
+
246
+ let unhandled: unknown;
247
+ const onUnhandled = (reason: unknown) => { unhandled = reason; };
248
+ process.once("unhandledRejection", onUnhandled);
249
+ await new Promise((r) => setTimeout(r, 250));
250
+ process.removeListener("unhandledRejection", onUnhandled);
251
+
252
+ assert.equal(unhandled, undefined, `expected no unhandled rejection, got: ${String(unhandled)}`);
253
+ assert.ok(
254
+ logs.some((l) => /retry attempt failed/.test(l) && /synthetic handshake throw/.test(l)),
255
+ `expected a logged retry failure, got: ${logs.join(" | ")}`,
256
+ );
257
+ await mgr.closeAll();
258
+ });
259
+
260
+ test("closing a connection twice in the same tick does not stack kill timers or exit listeners (F11)", async () => {
261
+ const { mgr, logs } = manager();
262
+ const conns = await mgr.connectAll({
263
+ stubborn3: { command: process.execPath, args: [FAKE], env: { FAKE_IGNORE_SIGTERM: "1" } },
264
+ });
265
+ const pidLine = logs.find((l) => l.includes("PID:"));
266
+ assert.ok(pidLine, `expected the fake server to report its pid, got: ${logs.join(" | ")}`);
267
+ const pid = Number(pidLine.split("PID:")[1]);
268
+ assert.ok(isAlive(pid), `expected pid ${pid} to be alive before close()`);
269
+
270
+ const start = Date.now();
271
+ conns[0].close();
272
+ conns[0].close(); // same tick: must not register a second SIGKILL timer / exit listener
273
+ while (isAlive(pid)) {
274
+ assert.ok(Date.now() - start < 2000, `pid ${pid} is still alive ${Date.now() - start}ms after double close()`);
275
+ await new Promise((r) => setTimeout(r, 20));
276
+ }
277
+ });
278
+
279
+ test("two concurrent connectAll calls do not interleave — the second supersedes the first cleanly", async () => {
280
+ const { mgr } = manager();
281
+ const p1 = mgr.connectAll({ a: { command: process.execPath, args: [FAKE] } });
282
+ const p2 = mgr.connectAll({ b: { command: process.execPath, args: [FAKE] } });
283
+ const [r1, r2] = await Promise.all([p1, p2]);
284
+
285
+ // Both calls settle successfully (the first is superseded, not aborted),
286
+ // but they must run one after the other — never interleaved.
287
+ assert.equal(r1.length, 1);
288
+ assert.equal(r1[0].name, "a");
289
+ assert.equal(r2.length, 1);
290
+ assert.equal(r2[0].name, "b");
291
+
292
+ // The surviving live state must match the second call's defs exactly —
293
+ // not a mix of both, and not zero connections.
294
+ const conns = mgr.connections();
295
+ assert.equal(conns.length, 1, `expected exactly one live connection, got: ${conns.map((c) => c.name).join(", ")}`);
296
+ assert.equal(conns[0].name, "b");
297
+
298
+ await mgr.closeAll();
299
+ });
@@ -0,0 +1,386 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { JsonRpcClient } from "./client.ts";
4
+ import type { ChannelServerDef } from "./config.ts";
5
+ import type { ChannelEvent } from "./envelope.ts";
6
+
7
+ export const CHANNEL_CAPABILITY = "claude/channel";
8
+ export const CHANNEL_NOTIFICATION = "notifications/claude/channel";
9
+
10
+ const PROTOCOL_VERSION = "2025-06-18";
11
+ const RETRY_BASE_MS = 30_000;
12
+ const RETRY_MAX_MS = 5 * 60_000;
13
+ // Some real channel servers ignore SIGTERM outright — only SIGKILL ends
14
+ // them. A well-behaved server exits on SIGTERM well inside this
15
+ // window, so the escalation almost never fires against it. Deliberately not
16
+ // unref'd: elsewhere in this file timers are unref'd so they cannot hold the
17
+ // process open, but here that would let Node exit before the SIGKILL is
18
+ // delivered, orphaning the very child this timer exists to kill.
19
+ const KILL_GRACE_MS = 300;
20
+
21
+ // Tracks children currently mid-kill so two killChild() calls landing in the
22
+ // same tick (e.g. conn.close() followed by closeAll() sweeping `pending`)
23
+ // don't each register their own SIGKILL timer and their own `exit` listener.
24
+ const killing = new WeakSet<ChildProcess>();
25
+
26
+ // SIGTERM, then SIGKILL after a short grace period if the child hasn't
27
+ // exited on its own. Cleared the moment the child's 'exit' fires, so a
28
+ // well-behaved server is never force-killed and a clean shutdown is never
29
+ // delayed.
30
+ function killChild(child: ChildProcess): void {
31
+ if (child.exitCode !== null || child.signalCode !== null) return;
32
+ if (killing.has(child)) return;
33
+ killing.add(child);
34
+ child.kill();
35
+ const timer = setTimeout(() => {
36
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
37
+ }, KILL_GRACE_MS);
38
+ child.once("exit", () => {
39
+ clearTimeout(timer);
40
+ killing.delete(child);
41
+ });
42
+ }
43
+
44
+ export type McpToolDef = {
45
+ name: string;
46
+ description?: string;
47
+ inputSchema?: Record<string, unknown>;
48
+ };
49
+
50
+ export type Connection = {
51
+ name: string;
52
+ client: JsonRpcClient;
53
+ instructions?: string;
54
+ tools: McpToolDef[];
55
+ close(): void;
56
+ };
57
+
58
+ type ManagerOpts = {
59
+ onEvent: (event: ChannelEvent) => void;
60
+ onStatus: (status: string) => void;
61
+ log: (message: string) => void;
62
+ // Fired after a *retry* connects a server that was not part of the
63
+ // connections array most recently returned by connectAll()/connections().
64
+ // Without this, a channel that reconnects 30s after session_start is live
65
+ // in `this.live` (instructions injected, events waking the agent) but was
66
+ // never handed back to the caller to re-run tool registration — the exact
67
+ // "healthy-looking but deaf channel" failure this component exists to
68
+ // avoid.
69
+ onConnected?: (connections: Connection[]) => void;
70
+ // Test seams only: production always uses the real RETRY_BASE_MS/
71
+ // RETRY_MAX_MS. Overriding lets tests exercise the retry path (including
72
+ // onConnected above) without waiting out a real 30s backoff.
73
+ retryBaseMs?: number;
74
+ retryMaxMs?: number;
75
+ };
76
+
77
+ export class ConnectionManager {
78
+ private live = new Map<string, { conn: Connection; child: ChildProcess }>();
79
+ // Children that have spawned and are mid-handshake, not yet in `live`.
80
+ // closeAll() must kill these directly — they are otherwise invisible to it,
81
+ // which is how a hung handshake used to survive closeAll() as an orphan.
82
+ private pending = new Set<ChildProcess>();
83
+ private retries = new Map<string, { attempts: number; timer?: NodeJS.Timeout }>();
84
+ private defs: Record<string, ChannelServerDef> = {};
85
+
86
+ // Bumped by closeAll(). Every connectOne()/retry call captures the
87
+ // generation it started with; if the generation has moved on by the time
88
+ // it would mutate shared state (this.live), it backs out instead of
89
+ // resurrecting a connection the caller believes is torn down.
90
+ private generation = 0;
91
+
92
+ // Serializes connectAll() calls. closeAll() has no internal await, so two
93
+ // unawaited connectAll() calls would otherwise both run their `await
94
+ // this.closeAll()` before either resumes, letting the second call's
95
+ // generation bump land underneath the first call's continuation — both
96
+ // then pass the generation check and end up live together. Each
97
+ // connectAll() call chains onto this promise instead of running
98
+ // immediately, so a queued call always starts only after the previous one
99
+ // has fully settled (including its own closeAll()), and captures the
100
+ // generation it will actually run under.
101
+ private queue: Promise<void> = Promise.resolve();
102
+
103
+ // Field + body assignment, not a constructor parameter property: Node's
104
+ // strip-only TypeScript rejects those with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
105
+ private readonly opts: ManagerOpts;
106
+ private readonly retryBaseMs: number;
107
+ private readonly retryMaxMs: number;
108
+
109
+ constructor(opts: ManagerOpts) {
110
+ this.opts = opts;
111
+ this.retryBaseMs = opts.retryBaseMs ?? RETRY_BASE_MS;
112
+ this.retryMaxMs = opts.retryMaxMs ?? RETRY_MAX_MS;
113
+ }
114
+
115
+ // Ordered by the configured server order (spec §4: "connection order for
116
+ // prefix stability"), not by whichever server happened to finish its
117
+ // handshake first — `this.live` is populated inside a `Promise.all`, so
118
+ // insertion order there varies run to run, and the instructions block
119
+ // built from this is a system-prompt prefix. A flipped order costs a full
120
+ // prompt-cache miss.
121
+ connections(): Connection[] {
122
+ const order = Object.keys(this.defs);
123
+ return [...this.live.values()]
124
+ .map((e) => e.conn)
125
+ .sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
126
+ }
127
+
128
+ // Deliberately not `async`: the chaining onto `this.queue` must happen
129
+ // synchronously, before control ever returns to the caller, so that a
130
+ // second connectAll() called back-to-back (no await in between) sees the
131
+ // link this call just made rather than the pre-call queue tail.
132
+ connectAll(defs: Record<string, ChannelServerDef>): Promise<Connection[]> {
133
+ const run = this.queue.then(() => this.runConnectAll(defs));
134
+ this.queue = run.then(
135
+ () => undefined,
136
+ () => undefined,
137
+ );
138
+ return run;
139
+ }
140
+
141
+ private async runConnectAll(defs: Record<string, ChannelServerDef>): Promise<Connection[]> {
142
+ await this.closeAll();
143
+ this.defs = defs;
144
+ const gen = this.generation;
145
+ const results = await Promise.all(
146
+ Object.entries(defs).map(([name, def]) => this.connectOne(name, def, gen)),
147
+ );
148
+ const ok = results.filter((c): c is Connection => c !== undefined);
149
+ this.publishStatus();
150
+ return ok;
151
+ }
152
+
153
+ private async connectOne(name: string, def: ChannelServerDef, gen: number): Promise<Connection | undefined> {
154
+ let child: ChildProcess;
155
+ try {
156
+ child = spawn(def.command, def.args ?? [], {
157
+ stdio: ["pipe", "pipe", "pipe"],
158
+ env: { ...process.env, ...(def.env ?? {}) },
159
+ ...(def.cwd ? { cwd: def.cwd } : {}),
160
+ });
161
+ } catch (error) {
162
+ this.opts.log(`channel ${name}: spawn failed: ${String(error)}`);
163
+ this.scheduleRetry(name, gen);
164
+ return undefined;
165
+ }
166
+
167
+ // spawn() is async: ENOENT arrives on 'error', not as a throw. Wiring the
168
+ // connection before this resolves turns a missing binary into a silent
169
+ // dead pipe. A failure here is transient-looking (the binary may appear
170
+ // later), so it is retried like any other startup failure.
171
+ const started = await new Promise<boolean>((resolve) => {
172
+ const onSpawn = () => { cleanup(); resolve(true); };
173
+ const onError = (err: Error) => {
174
+ cleanup();
175
+ this.opts.log(`channel ${name}: spawn failed: ${err.message}`);
176
+ resolve(false);
177
+ };
178
+ const cleanup = () => {
179
+ child.removeListener("spawn", onSpawn);
180
+ child.removeListener("error", onError);
181
+ };
182
+ child.once("spawn", onSpawn);
183
+ child.once("error", onError);
184
+ });
185
+
186
+ if (!started) {
187
+ this.scheduleRetry(name, gen);
188
+ return undefined;
189
+ }
190
+ if (!child.stdin || !child.stdout) {
191
+ killChild(child);
192
+ this.scheduleRetry(name, gen);
193
+ return undefined;
194
+ }
195
+ if (gen !== this.generation) {
196
+ // closeAll() ran while we were waiting on the spawn/error gate.
197
+ killChild(child);
198
+ return undefined;
199
+ }
200
+
201
+ this.pending.add(child);
202
+ try {
203
+ return await this.handshake(name, def, gen, child);
204
+ } finally {
205
+ this.pending.delete(child);
206
+ }
207
+ }
208
+
209
+ private async handshake(
210
+ name: string,
211
+ def: ChannelServerDef,
212
+ gen: number,
213
+ child: ChildProcess,
214
+ ): Promise<Connection | undefined> {
215
+ // If the child's own pipe is already broken (it died before we got here,
216
+ // or dies mid-write), a write must not surface as an unhandled 'error'
217
+ // event and crash the process — the exit handler below is what reports
218
+ // the death.
219
+ // The handler must stay — an unhandled stream 'error' crashes the
220
+ // process — but it must log. Silently swallowing a *live* pipe error
221
+ // stops delivery with no 'exit' event, so no retry is scheduled and
222
+ // publishStatus() keeps reporting healthy: mail is then lost silently
223
+ // and indefinitely with zero evidence.
224
+ child.stdin?.on("error", (err) => this.opts.log(`channel ${name}: stdin error: ${String(err)}`));
225
+ child.stdout?.on("error", (err) => this.opts.log(`channel ${name}: stdout error: ${String(err)}`));
226
+
227
+ child.stderr?.on("data", (chunk: Buffer) => {
228
+ const text = chunk.toString("utf-8").trim();
229
+ if (text !== "") this.opts.log(`channel ${name} stderr: ${text}`);
230
+ });
231
+
232
+ const client = new JsonRpcClient({ stdin: child.stdin!, stdout: child.stdout! });
233
+
234
+ // Registered BEFORE the handshake requests: a child that dies mid-
235
+ // handshake must not fire 'exit' into a void (a listener installed only
236
+ // after `initialize` resolves would miss it entirely, leaving a dead
237
+ // child silently inserted into `live` as if it were healthy). Closing
238
+ // the client here makes the in-flight request reject immediately
239
+ // instead of waiting out JsonRpcClient's 30s timeout.
240
+ let handshakeDone = false;
241
+ child.once("exit", (code) => {
242
+ const entry = this.live.get(name);
243
+ if (entry && entry.child === child) {
244
+ this.live.delete(name);
245
+ this.opts.log(`channel ${name}: exited (code ${code ?? "null"})`);
246
+ this.publishStatus();
247
+ this.scheduleRetry(name, gen);
248
+ return;
249
+ }
250
+ if (!handshakeDone) {
251
+ this.opts.log(`channel ${name}: exited during handshake (code ${code ?? "null"})`);
252
+ client.close();
253
+ }
254
+ });
255
+
256
+ let result: Record<string, unknown>;
257
+ try {
258
+ result = (await client.request("initialize", {
259
+ protocolVersion: PROTOCOL_VERSION,
260
+ capabilities: {},
261
+ clientInfo: { name: "channels.tools", version: "0.1.0" },
262
+ })) as Record<string, unknown>;
263
+ } catch (error) {
264
+ this.opts.log(`channel ${name}: initialize failed: ${String(error)}`);
265
+ handshakeDone = true;
266
+ client.close();
267
+ killChild(child);
268
+ this.scheduleRetry(name, gen);
269
+ return undefined;
270
+ }
271
+
272
+ if (gen !== this.generation) {
273
+ handshakeDone = true;
274
+ client.close();
275
+ killChild(child);
276
+ return undefined;
277
+ }
278
+
279
+ const capabilities = (result.capabilities ?? {}) as { experimental?: Record<string, unknown> };
280
+ if (!capabilities.experimental || !(CHANNEL_CAPABILITY in capabilities.experimental)) {
281
+ this.opts.log(`channel ${name}: rejected — no ${CHANNEL_CAPABILITY} capability declared`);
282
+ handshakeDone = true;
283
+ client.close();
284
+ killChild(child);
285
+ // Permanent incompatibility, not a transient fault: never retried.
286
+ return undefined;
287
+ }
288
+
289
+ client.notify("notifications/initialized");
290
+
291
+ let tools: McpToolDef[] = [];
292
+ try {
293
+ const listed = (await client.request("tools/list", {})) as { tools?: McpToolDef[] };
294
+ tools = listed.tools ?? [];
295
+ } catch (error) {
296
+ this.opts.log(`channel ${name}: tools/list failed: ${String(error)}`);
297
+ }
298
+
299
+ handshakeDone = true;
300
+
301
+ if (gen !== this.generation) {
302
+ client.close();
303
+ killChild(child);
304
+ return undefined;
305
+ }
306
+
307
+ client.onNotification((method, params) => {
308
+ if (method !== CHANNEL_NOTIFICATION) return;
309
+ const p = (params ?? {}) as { content?: unknown; meta?: unknown };
310
+ const meta: Record<string, string> = {};
311
+ if (typeof p.meta === "object" && p.meta !== null) {
312
+ for (const [k, v] of Object.entries(p.meta as Record<string, unknown>)) {
313
+ if (v !== undefined && v !== null) meta[k] = String(v);
314
+ }
315
+ }
316
+ this.opts.onEvent({ source: name, content: String(p.content ?? ""), meta });
317
+ });
318
+
319
+ const conn: Connection = {
320
+ name,
321
+ client,
322
+ ...(typeof result.instructions === "string" ? { instructions: result.instructions } : {}),
323
+ tools,
324
+ close: () => { client.close(); killChild(child); },
325
+ };
326
+
327
+ this.live.set(name, { conn, child });
328
+ this.retries.delete(name);
329
+ return conn;
330
+ }
331
+
332
+ private scheduleRetry(name: string, gen: number): void {
333
+ if (gen !== this.generation) return;
334
+ const def = this.defs[name];
335
+ if (!def) return;
336
+ const state = this.retries.get(name) ?? { attempts: 0 };
337
+ state.attempts += 1;
338
+ const delay = Math.min(this.retryBaseMs * 2 ** (state.attempts - 1), this.retryMaxMs);
339
+ this.opts.log(`channel ${name}: retrying in ${Math.round(delay / 1000)}s (attempt ${state.attempts})`);
340
+ const timer = setTimeout(() => {
341
+ if (gen !== this.generation) return;
342
+ this.connectOne(name, def, gen)
343
+ .then((conn) => {
344
+ if (!conn || gen !== this.generation) return;
345
+ this.publishStatus();
346
+ // The caller (index.ts) is not otherwise notified that a
347
+ // previously-failed channel just came up: its instructions and
348
+ // events are now live via `this.live`, but the tool layer never
349
+ // heard about it without this callback.
350
+ this.opts.onConnected?.(this.connections());
351
+ })
352
+ .catch((error) => {
353
+ // connectOne's own error paths all resolve rather than reject;
354
+ // this only catches an unexpected throw (e.g. inside handshake).
355
+ // Without a rejection handler here, that throw becomes an
356
+ // unhandled rejection and exits the process under Node's default.
357
+ this.opts.log(`channel ${name}: retry attempt failed: ${String(error)}`);
358
+ });
359
+ }, delay);
360
+ if (typeof timer.unref === "function") timer.unref();
361
+ state.timer = timer;
362
+ this.retries.set(name, state);
363
+ }
364
+
365
+ private publishStatus(): void {
366
+ const configured = Object.keys(this.defs).length;
367
+ const up = this.live.size;
368
+ if (configured === 0) { this.opts.onStatus(""); return; }
369
+ // A dead channel loses mail silently, so degraded state must be visible.
370
+ this.opts.onStatus(up === configured ? `channels ${up}/${configured}` : `channels ${up}/${configured} DEGRADED`);
371
+ }
372
+
373
+ async closeAll(): Promise<void> {
374
+ this.generation++;
375
+ for (const [, state] of this.retries) if (state.timer) clearTimeout(state.timer);
376
+ this.retries.clear();
377
+ for (const [, entry] of this.live) entry.conn.close();
378
+ this.live.clear();
379
+ // Kill anything still mid-handshake too — otherwise a hung handshake
380
+ // outlives closeAll() as an orphan process.
381
+ for (const child of this.pending) killChild(child);
382
+ this.pending.clear();
383
+ this.defs = {};
384
+ this.opts.onStatus("");
385
+ }
386
+ }