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/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # channels.tools
2
+
3
+ Channels for the [pi](https://pi.dev) coding agent. Connect **channel
4
+ servers** — small processes that push events from the outside world into a
5
+ live session — and the session becomes reachable: a push **wakes it when
6
+ idle** and **queues, coalesced, when it's mid-turn**. Each server's tools are
7
+ registered so the agent can act on what arrived.
8
+
9
+ Mail buses, code-review watchers, CI, file watchers, webhooks — anything that
10
+ can speak the (simple) protocol below can drive an agent session.
11
+
12
+ Site: [channels.tools](https://channels.tools)
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:channels.tools
18
+ ```
19
+
20
+ ## Configure
21
+
22
+ `~/.pi/agent/channels.json` (global) or `<project>/.pi/channels.json`
23
+ (project wins on name collision). Nothing connects until one of these files
24
+ exists:
25
+
26
+ ```json
27
+ { "channelServers": {
28
+ "file": { "command": "node", "args": ["examples/file-channel.js", "inbox.txt"] },
29
+ "my-bus": { "command": "mybus", "args": ["channel"], "env": {}, "cwd": "." }
30
+ } }
31
+ ```
32
+
33
+ Real-world servers: [muster](https://muster.tools) (`muster channel`, a
34
+ multi-agent mail bus) and galley (`galley channel`, document review) both
35
+ speak this protocol.
36
+
37
+ ## Behavior
38
+
39
+ - An event arriving while the session is idle starts a turn immediately
40
+ (delivered as a steer with the server's event payload).
41
+ - Events arriving mid-turn are buffered and delivered coalesced once the turn
42
+ settles (pi's `agent_settled`, not `agent_end`) — a running turn is never
43
+ interrupted.
44
+ - Each server's tools are registered under their own names, so instructions a
45
+ server hands the agent stay literally true.
46
+ - `AGENT_SESSION_ID` is exported before any server spawns; servers can read
47
+ it at startup to scope themselves to this session.
48
+ - A server that crashes is retried with backoff; its tools re-register on
49
+ reconnect. A misbehaving or non-conforming process is killed and logged.
50
+
51
+ ## The protocol (`claude/channel` convention)
52
+
53
+ A channel server is a subprocess speaking **newline-delimited JSON-RPC 2.0
54
+ over stdio** (no `Content-Length` framing). Originating as an experimental
55
+ Claude Code convention, hence the capability name.
56
+
57
+ 1. **Handshake** — client sends `initialize`; the server's result must
58
+ declare `capabilities.experimental["claude/channel"]` and may include an
59
+ `instructions` string (injected as guidance) and `tools` (see below).
60
+ Servers not declaring the capability are rejected.
61
+ 2. **Events** — the server pushes `notifications/claude/channel` with a
62
+ `params` payload: freeform text plus optional `meta` attributes
63
+ (`count`, `thread_id`, `from`, …). Multiple notifications may be
64
+ coalesced by the client; servers that pre-coalesce should say how many
65
+ events one push represents in `meta.count`.
66
+ 3. **Tools** — servers may expose tools (name, description, JSON-schema
67
+ input). The client registers them with pi and proxies invocations via
68
+ `tools/call`.
69
+ 4. **Shutdown** — SIGTERM on session end (SIGKILL after a grace period).
70
+
71
+ The reference server in [`examples/file-channel.js`](examples/file-channel.js)
72
+ implements the whole convention in ~60 dependency-free lines: append a line
73
+ to a watched file and an idle pi session wakes with it.
74
+
75
+ ```bash
76
+ node examples/file-channel.js --demo # prints the wire exchange, no pi needed
77
+ ```
78
+
79
+ ## Test
80
+
81
+ ```bash
82
+ npm test # unit, no binaries needed
83
+ node --test test/live.test.ts # integration; skips servers not installed
84
+ ```
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ // Reference channel server for the claude/channel convention.
3
+ //
4
+ // Watches a file; every line appended to it is pushed into the connected pi
5
+ // session as a channel event. Declares one tool (file_channel_status) so the
6
+ // tool-proxy path is exercised too.
7
+ //
8
+ // node file-channel.js <path> run as a channel server (pi spawns this)
9
+ // node file-channel.js --demo print the wire exchange, no pi needed
10
+ //
11
+ // Wire format: newline-delimited JSON-RPC 2.0 over stdio.
12
+
13
+ import { openSync, readSync, fstatSync, watchFile, closeSync } from "node:fs";
14
+
15
+ const NOTIFICATION = "notifications/claude/channel";
16
+ const CAPABILITY = "claude/channel";
17
+
18
+ const arg = process.argv[2];
19
+ if (!arg || arg === "--help") {
20
+ console.error("usage: file-channel.js <path-to-watch> | --demo");
21
+ process.exit(2);
22
+ }
23
+
24
+ if (arg === "--demo") {
25
+ const show = (dir, msg) => console.log(`${dir} ${JSON.stringify(msg)}`);
26
+ show("<-", { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "channels.tools", version: "0.1.0" } } });
27
+ show("->", { jsonrpc: "2.0", id: 1, result: { capabilities: { experimental: { [CAPABILITY]: {} } }, instructions: "Lines appended to the watched file arrive as channel events." } });
28
+ show("<-", { jsonrpc: "2.0", method: "notifications/initialized" });
29
+ show("<-", { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
30
+ show("->", { jsonrpc: "2.0", id: 2, result: { tools: [{ name: "file_channel_status", description: "Report the watched path and lines delivered.", inputSchema: { type: "object", properties: {} } }] } });
31
+ show("->", { jsonrpc: "2.0", method: NOTIFICATION, params: { content: "new line appended: hello", meta: { count: 1, source_path: "inbox.txt" } } });
32
+ process.exit(0);
33
+ }
34
+
35
+ const watchedPath = arg;
36
+ let delivered = 0;
37
+ let offset = 0;
38
+
39
+ function send(msg) {
40
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...msg })}\n`);
41
+ }
42
+
43
+ function drainNewLines() {
44
+ let fd;
45
+ try {
46
+ fd = openSync(watchedPath, "r");
47
+ } catch {
48
+ return; // file may not exist yet; watchFile fires again when it does
49
+ }
50
+ try {
51
+ const size = fstatSync(fd).size;
52
+ if (size < offset) offset = 0; // truncated: start over
53
+ if (size === offset) return;
54
+ const buf = Buffer.alloc(size - offset);
55
+ readSync(fd, buf, 0, buf.length, offset);
56
+ offset = size;
57
+ const lines = buf.toString("utf-8").split("\n").filter((l) => l.trim() !== "");
58
+ if (lines.length === 0) return;
59
+ delivered += lines.length;
60
+ send({
61
+ method: NOTIFICATION,
62
+ params: {
63
+ content: lines.join("\n"),
64
+ meta: { count: lines.length, source_path: watchedPath },
65
+ },
66
+ });
67
+ } finally {
68
+ closeSync(fd);
69
+ }
70
+ }
71
+
72
+ let buffered = "";
73
+ process.stdin.setEncoding("utf-8");
74
+ process.stdin.on("data", (chunk) => {
75
+ buffered += chunk;
76
+ let nl;
77
+ while ((nl = buffered.indexOf("\n")) >= 0) {
78
+ const line = buffered.slice(0, nl);
79
+ buffered = buffered.slice(nl + 1);
80
+ if (line.trim() === "") continue;
81
+ let msg;
82
+ try {
83
+ msg = JSON.parse(line);
84
+ } catch {
85
+ continue;
86
+ }
87
+ handle(msg);
88
+ }
89
+ });
90
+ process.stdin.on("end", () => process.exit(0));
91
+
92
+ function handle(msg) {
93
+ if (msg.method === "initialize") {
94
+ send({
95
+ id: msg.id,
96
+ result: {
97
+ protocolVersion: "2025-06-18",
98
+ capabilities: { experimental: { [CAPABILITY]: {} } },
99
+ serverInfo: { name: "file-channel", version: "0.1.0" },
100
+ instructions:
101
+ `Lines appended to ${watchedPath} arrive as channel events. ` +
102
+ "Call file_channel_status to see what has been delivered.",
103
+ },
104
+ });
105
+ // Start from the file's current end: only NEW lines become events.
106
+ try {
107
+ const fd = openSync(watchedPath, "r");
108
+ offset = fstatSync(fd).size;
109
+ closeSync(fd);
110
+ } catch {
111
+ offset = 0;
112
+ }
113
+ watchFile(watchedPath, { interval: 500 }, drainNewLines);
114
+ return;
115
+ }
116
+ if (msg.method === "tools/list") {
117
+ send({
118
+ id: msg.id,
119
+ result: {
120
+ tools: [
121
+ {
122
+ name: "file_channel_status",
123
+ description: "Report the watched path and how many lines have been delivered.",
124
+ inputSchema: { type: "object", properties: {} },
125
+ },
126
+ ],
127
+ },
128
+ });
129
+ return;
130
+ }
131
+ if (msg.method === "tools/call") {
132
+ const name = msg.params?.name;
133
+ if (name === "file_channel_status") {
134
+ send({
135
+ id: msg.id,
136
+ result: {
137
+ content: [{ type: "text", text: `watching ${watchedPath}; ${delivered} line(s) delivered` }],
138
+ },
139
+ });
140
+ } else {
141
+ send({ id: msg.id, error: { code: -32601, message: `unknown tool: ${name}` } });
142
+ }
143
+ return;
144
+ }
145
+ if (msg.id !== undefined) {
146
+ // Unknown request: answer rather than dangle the client's promise.
147
+ send({ id: msg.id, error: { code: -32601, message: `unknown method: ${msg.method}` } });
148
+ }
149
+ }
150
+
151
+ process.on("SIGTERM", () => process.exit(0));
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "channels.tools",
3
+ "version": "0.1.0",
4
+ "description": "Channels for the pi coding agent: connect channel servers that push events into a live session — wake it when idle, queue when busy — and proxy their tools. A client for the claude/channel convention.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://channels.tools",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/schuettc/pi-extensions.git",
11
+ "directory": "packages/channels.tools"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "channels",
18
+ "claude-channel",
19
+ "agent-wake",
20
+ "event-driven"
21
+ ],
22
+ "files": [
23
+ "src",
24
+ "examples",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ]
31
+ },
32
+ "scripts": {
33
+ "typecheck": "tsc --noEmit -p tsconfig.json",
34
+ "test": "node --experimental-strip-types --test src/*.test.ts"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.20.1",
38
+ "typescript": "^5.6.0"
39
+ }
40
+ }
@@ -0,0 +1,136 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { PassThrough } from "node:stream";
4
+ import { JsonRpcClient } from "./client.ts";
5
+
6
+ function pair() {
7
+ const stdin = new PassThrough(); // what the client writes
8
+ const stdout = new PassThrough(); // what the client reads
9
+ const client = new JsonRpcClient({ stdin, stdout });
10
+ return { client, stdin, stdout };
11
+ }
12
+
13
+ function nextLine(stream: PassThrough): Promise<Record<string, unknown>> {
14
+ return new Promise((resolve) => {
15
+ let buf = "";
16
+ stream.on("data", (chunk: Buffer) => {
17
+ buf += chunk.toString("utf-8");
18
+ const nl = buf.indexOf("\n");
19
+ if (nl >= 0) resolve(JSON.parse(buf.slice(0, nl)));
20
+ });
21
+ });
22
+ }
23
+
24
+ test("request writes a framed JSON-RPC call and resolves on the matching id", async () => {
25
+ const { client, stdin, stdout } = pair();
26
+ const pending = client.request("initialize", { protocolVersion: "2025-06-18" });
27
+ const sent = await nextLine(stdin);
28
+ assert.equal(sent.jsonrpc, "2.0");
29
+ assert.equal(sent.method, "initialize");
30
+ assert.deepEqual(sent.params, { protocolVersion: "2025-06-18" });
31
+ stdout.write(JSON.stringify({ jsonrpc: "2.0", id: sent.id, result: { ok: true } }) + "\n");
32
+ assert.deepEqual(await pending, { ok: true });
33
+ });
34
+
35
+ test("a JSON-RPC error response rejects", async () => {
36
+ const { client, stdin, stdout } = pair();
37
+ const pending = client.request("tools/call");
38
+ const sent = await nextLine(stdin);
39
+ stdout.write(JSON.stringify({ jsonrpc: "2.0", id: sent.id, error: { code: -32601, message: "no such tool" } }) + "\n");
40
+ await assert.rejects(pending, /no such tool/);
41
+ });
42
+
43
+ test("notifications reach the handler and carry method and params", async () => {
44
+ const { client, stdout } = pair();
45
+ const seen: Array<{ method: string; params: unknown }> = [];
46
+ client.onNotification((method, params) => seen.push({ method, params }));
47
+ stdout.write(JSON.stringify({
48
+ jsonrpc: "2.0",
49
+ method: "notifications/claude/channel",
50
+ params: { content: "hello", meta: { intent: "fyi" } },
51
+ }) + "\n");
52
+ await new Promise((r) => setImmediate(r));
53
+ assert.equal(seen.length, 1);
54
+ assert.equal(seen[0].method, "notifications/claude/channel");
55
+ assert.deepEqual(seen[0].params, { content: "hello", meta: { intent: "fyi" } });
56
+ });
57
+
58
+ test("a message split across chunk boundaries is reassembled", async () => {
59
+ const { client, stdout } = pair();
60
+ const seen: string[] = [];
61
+ client.onNotification((method) => seen.push(method));
62
+ stdout.write('{"jsonrpc":"2.0","meth');
63
+ stdout.write('od":"notifications/x"}\n');
64
+ await new Promise((r) => setImmediate(r));
65
+ assert.deepEqual(seen, ["notifications/x"]);
66
+ });
67
+
68
+ test("two messages in one chunk are both delivered", async () => {
69
+ const { client, stdout } = pair();
70
+ const seen: string[] = [];
71
+ client.onNotification((method) => seen.push(method));
72
+ stdout.write('{"jsonrpc":"2.0","method":"a"}\n{"jsonrpc":"2.0","method":"b"}\n');
73
+ await new Promise((r) => setImmediate(r));
74
+ assert.deepEqual(seen, ["a", "b"]);
75
+ });
76
+
77
+ test("an unparseable line is skipped without killing the stream", async () => {
78
+ const { client, stdout } = pair();
79
+ const seen: string[] = [];
80
+ client.onNotification((method) => seen.push(method));
81
+ stdout.write('not json\n{"jsonrpc":"2.0","method":"after"}\n');
82
+ await new Promise((r) => setImmediate(r));
83
+ assert.deepEqual(seen, ["after"]);
84
+ });
85
+
86
+ test("close rejects every in-flight request", async () => {
87
+ const { client, stdin } = pair();
88
+ const pending = client.request("initialize");
89
+ await nextLine(stdin);
90
+ client.close();
91
+ await assert.rejects(pending, /closed/);
92
+ });
93
+
94
+ test("a notification carrying \"id\": null is dispatched, not dropped (F8)", async () => {
95
+ const { client, stdout } = pair();
96
+ const seen: Array<{ method: string; params: unknown }> = [];
97
+ client.onNotification((method, params) => seen.push({ method, params }));
98
+ stdout.write(JSON.stringify({
99
+ jsonrpc: "1.0",
100
+ id: null,
101
+ method: "notifications/claude/channel",
102
+ params: { content: "mail", meta: {} },
103
+ }) + "\n");
104
+ await new Promise((r) => setImmediate(r));
105
+ assert.equal(seen.length, 1, "an id:null notification must not be silently dropped");
106
+ assert.equal(seen[0].method, "notifications/claude/channel");
107
+ });
108
+
109
+ // Race the pending request against a short timer rather than awaiting it
110
+ // directly: pre-fix, an aborted signal is never honored and the request
111
+ // never settles, so a direct assert.rejects() would hang instead of failing.
112
+ async function raceAbort(pending: Promise<unknown>): Promise<"resolved" | "rejected" | "pending"> {
113
+ return Promise.race([
114
+ pending.then(() => "resolved" as const, () => "rejected" as const),
115
+ new Promise<"pending">((r) => setTimeout(() => r("pending"), 300)),
116
+ ]);
117
+ }
118
+
119
+ test("aborting the signal after send rejects the pending request and stops the timer (F6)", async () => {
120
+ const { client, stdin } = pair();
121
+ const controller = new AbortController();
122
+ const pending = client.request("tools/call", { name: "x" }, 30_000, controller.signal);
123
+ await nextLine(stdin);
124
+ controller.abort();
125
+ assert.equal(await raceAbort(pending), "rejected");
126
+ await assert.rejects(pending, /aborted/);
127
+ });
128
+
129
+ test("an already-aborted signal rejects immediately without sending (F6)", async () => {
130
+ const { client } = pair();
131
+ const controller = new AbortController();
132
+ controller.abort();
133
+ const pending = client.request("tools/call", {}, 30_000, controller.signal);
134
+ assert.equal(await raceAbort(pending), "rejected");
135
+ await assert.rejects(pending, /aborted/);
136
+ });
package/src/client.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+
3
+ type NotificationHandler = (method: string, params: unknown) => void;
4
+ type Pending = {
5
+ resolve: (v: unknown) => void;
6
+ reject: (e: Error) => void;
7
+ timer?: NodeJS.Timeout;
8
+ // Removes the abort listener registered for this request, if any. Called
9
+ // on every settlement path (response, timeout, close) so an aborted-later
10
+ // signal never fires into a request that already finished.
11
+ cleanup?: () => void;
12
+ };
13
+
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+
16
+ export class JsonRpcClient {
17
+ private streams: { stdin: Writable; stdout: Readable };
18
+ private nextId = 1;
19
+ private pending = new Map<number, Pending>();
20
+ private handlers: NotificationHandler[] = [];
21
+ private buffer = "";
22
+ private closed = false;
23
+
24
+ constructor(streams: { stdin: Writable; stdout: Readable }) {
25
+ this.streams = streams;
26
+ this.streams.stdout.on("data", (chunk: Buffer) => this.onData(chunk));
27
+ }
28
+
29
+ private onData(chunk: Buffer): void {
30
+ this.buffer += chunk.toString("utf-8");
31
+ let nl = this.buffer.indexOf("\n");
32
+ while (nl >= 0) {
33
+ const line = this.buffer.slice(0, nl).trim();
34
+ this.buffer = this.buffer.slice(nl + 1);
35
+ if (line !== "") this.dispatch(line);
36
+ nl = this.buffer.indexOf("\n");
37
+ }
38
+ }
39
+
40
+ private dispatch(line: string): void {
41
+ let msg: Record<string, unknown>;
42
+ try {
43
+ msg = JSON.parse(line) as Record<string, unknown>;
44
+ } catch {
45
+ // A server that writes a stray line must not take the channel down.
46
+ return;
47
+ }
48
+ // A JSON-RPC 1.0-flavored notification carries "id": null rather than
49
+ // omitting id entirely. Requiring strict `undefined` here silently drops
50
+ // that shape — a notification is mail, so this is the silent-mail-loss
51
+ // failure class.
52
+ if (typeof msg.method === "string" && (msg.id === undefined || msg.id === null)) {
53
+ for (const h of this.handlers) h(msg.method, msg.params);
54
+ return;
55
+ }
56
+ if (typeof msg.id !== "number") return;
57
+ const entry = this.pending.get(msg.id);
58
+ if (!entry) return;
59
+ this.pending.delete(msg.id);
60
+ if (entry.timer) clearTimeout(entry.timer);
61
+ entry.cleanup?.();
62
+ const err = msg.error as { message?: string } | undefined;
63
+ if (err) entry.reject(new Error(err.message ?? "JSON-RPC error"));
64
+ else entry.resolve(msg.result);
65
+ }
66
+
67
+ // `signal` threads pi's own abort signal (from `execute(id, params, signal,
68
+ // …)`) through to the pending request so an aborted turn rejects the call
69
+ // immediately instead of waiting out the full timeout with nothing to show
70
+ // for it.
71
+ request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS, signal?: AbortSignal): Promise<unknown> {
72
+ if (this.closed) return Promise.reject(new Error("channel client is closed"));
73
+ if (signal?.aborted) return Promise.reject(new Error(`aborted before send: ${method}`));
74
+ const id = this.nextId++;
75
+ return new Promise((resolve, reject) => {
76
+ const timer = setTimeout(() => {
77
+ const entry = this.pending.get(id);
78
+ this.pending.delete(id);
79
+ entry?.cleanup?.();
80
+ reject(new Error(`timed out after ${timeoutMs}ms: ${method}`));
81
+ }, timeoutMs);
82
+ if (typeof timer.unref === "function") timer.unref();
83
+
84
+ let cleanup: (() => void) | undefined;
85
+ if (signal) {
86
+ const onAbort = () => {
87
+ if (!this.pending.has(id)) return;
88
+ this.pending.delete(id);
89
+ clearTimeout(timer);
90
+ reject(new Error(`aborted: ${method}`));
91
+ };
92
+ signal.addEventListener("abort", onAbort, { once: true });
93
+ cleanup = () => signal.removeEventListener("abort", onAbort);
94
+ }
95
+
96
+ this.pending.set(id, { resolve, reject, timer, ...(cleanup ? { cleanup } : {}) });
97
+ this.write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) });
98
+ });
99
+ }
100
+
101
+ notify(method: string, params?: unknown): void {
102
+ if (this.closed) return;
103
+ this.write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) });
104
+ }
105
+
106
+ onNotification(handler: NotificationHandler): void {
107
+ this.handlers.push(handler);
108
+ }
109
+
110
+ close(): void {
111
+ if (this.closed) return;
112
+ this.closed = true;
113
+ for (const [, entry] of this.pending) {
114
+ if (entry.timer) clearTimeout(entry.timer);
115
+ entry.cleanup?.();
116
+ entry.reject(new Error("channel client is closed"));
117
+ }
118
+ this.pending.clear();
119
+ }
120
+
121
+ private write(msg: unknown): void {
122
+ this.streams.stdin.write(JSON.stringify(msg) + "\n");
123
+ }
124
+ }
@@ -0,0 +1,72 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { loadChannelConfig } from "./config.ts";
7
+
8
+ function scratch(): string {
9
+ return mkdtempSync(join(tmpdir(), "pi-channels-config-"));
10
+ }
11
+
12
+ test("returns empty when no config files exist", () => {
13
+ const home = scratch();
14
+ const cwd = scratch();
15
+ assert.deepEqual(loadChannelConfig({ home, cwd }), {});
16
+ });
17
+
18
+ test("reads the global config", () => {
19
+ const home = scratch();
20
+ const cwd = scratch();
21
+ mkdirSync(join(home, ".pi", "agent"), { recursive: true });
22
+ writeFileSync(
23
+ join(home, ".pi", "agent", "channels.json"),
24
+ JSON.stringify({ channelServers: { "muster-channel": { command: "muster", args: ["channel"] } } }),
25
+ );
26
+ assert.deepEqual(loadChannelConfig({ home, cwd }), {
27
+ "muster-channel": { command: "muster", args: ["channel"] },
28
+ });
29
+ });
30
+
31
+ test("project config overrides global by name and adds new names", () => {
32
+ const home = scratch();
33
+ const cwd = scratch();
34
+ mkdirSync(join(home, ".pi", "agent"), { recursive: true });
35
+ writeFileSync(
36
+ join(home, ".pi", "agent", "channels.json"),
37
+ JSON.stringify({ channelServers: { galley: { command: "galley", args: ["channel"] } } }),
38
+ );
39
+ mkdirSync(join(cwd, ".pi"), { recursive: true });
40
+ writeFileSync(
41
+ join(cwd, ".pi", "channels.json"),
42
+ JSON.stringify({
43
+ channelServers: {
44
+ galley: { command: "galley", args: ["channel", "--scope", "."] },
45
+ "muster-channel": { command: "muster", args: ["channel"] },
46
+ },
47
+ }),
48
+ );
49
+ assert.deepEqual(loadChannelConfig({ home, cwd }), {
50
+ galley: { command: "galley", args: ["channel", "--scope", "."] },
51
+ "muster-channel": { command: "muster", args: ["channel"] },
52
+ });
53
+ });
54
+
55
+ test("malformed JSON is skipped, not thrown", () => {
56
+ const home = scratch();
57
+ const cwd = scratch();
58
+ mkdirSync(join(home, ".pi", "agent"), { recursive: true });
59
+ writeFileSync(join(home, ".pi", "agent", "channels.json"), "{ not json");
60
+ assert.deepEqual(loadChannelConfig({ home, cwd }), {});
61
+ });
62
+
63
+ test("entries without a command string are rejected", () => {
64
+ const home = scratch();
65
+ const cwd = scratch();
66
+ mkdirSync(join(home, ".pi", "agent"), { recursive: true });
67
+ writeFileSync(
68
+ join(home, ".pi", "agent", "channels.json"),
69
+ JSON.stringify({ channelServers: { broken: { args: ["x"] }, ok: { command: "muster" } } }),
70
+ );
71
+ assert.deepEqual(loadChannelConfig({ home, cwd }), { ok: { command: "muster" } });
72
+ });
package/src/config.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type ChannelServerDef = {
5
+ command: string;
6
+ args?: string[];
7
+ env?: Record<string, string>;
8
+ cwd?: string;
9
+ };
10
+
11
+ function readServers(path: string): Record<string, ChannelServerDef> {
12
+ let raw: string;
13
+ try {
14
+ raw = readFileSync(path, "utf-8");
15
+ } catch {
16
+ return {};
17
+ }
18
+ let parsed: unknown;
19
+ try {
20
+ parsed = JSON.parse(raw);
21
+ } catch {
22
+ // A malformed file must not take the session down; the connection
23
+ // layer surfaces "no channels configured" instead.
24
+ return {};
25
+ }
26
+ if (typeof parsed !== "object" || parsed === null) return {};
27
+ const servers = (parsed as { channelServers?: unknown }).channelServers;
28
+ if (typeof servers !== "object" || servers === null) return {};
29
+
30
+ const out: Record<string, ChannelServerDef> = {};
31
+ for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {
32
+ if (typeof value !== "object" || value === null) continue;
33
+ const def = value as Partial<ChannelServerDef>;
34
+ if (typeof def.command !== "string" || def.command === "") continue;
35
+ out[name] = { command: def.command, ...(def.args ? { args: def.args } : {}),
36
+ ...(def.env ? { env: def.env } : {}), ...(def.cwd ? { cwd: def.cwd } : {}) };
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export function loadChannelConfig(opts: { home: string; cwd: string }): Record<string, ChannelServerDef> {
42
+ const global = readServers(join(opts.home, ".pi", "agent", "channels.json"));
43
+ const project = readServers(join(opts.cwd, ".pi", "channels.json"));
44
+ return { ...global, ...project };
45
+ }