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 +84 -0
- package/examples/file-channel.js +151 -0
- package/package.json +40 -0
- package/src/client.test.ts +136 -0
- package/src/client.ts +124 -0
- package/src/config.test.ts +72 -0
- package/src/config.ts +45 -0
- package/src/connection.test.ts +299 -0
- package/src/connection.ts +386 -0
- package/src/envelope.test.ts +132 -0
- package/src/envelope.ts +94 -0
- package/src/identity.test.ts +34 -0
- package/src/identity.ts +13 -0
- package/src/index.test.ts +293 -0
- package/src/index.ts +215 -0
- package/src/tools.test.ts +109 -0
- package/src/tools.ts +66 -0
- package/src/wake.test.ts +121 -0
- package/src/wake.ts +76 -0
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
|
+
}
|