channels.tools 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # channels.tools
2
2
 
3
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
4
+ servers** — small local processes that push events into a live session and
5
+ the session becomes reachable by your other tools: a push **wakes it when
6
6
  idle** and **queues, coalesced, when it's mid-turn**. Each server's tools are
7
7
  registered so the agent can act on what arrived.
8
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.
9
+ Mail buses, code-review servers, CI and file watchers — anything on your
10
+ machine that can speak the (simple) protocol below can drive an agent
11
+ session. Servers are subprocesses the session itself spawns from your config;
12
+ nothing listens on a network.
11
13
 
12
14
  Site: [channels.tools](https://channels.tools)
13
15
 
@@ -51,8 +53,8 @@ speak this protocol.
51
53
  ## The protocol (`claude/channel` convention)
52
54
 
53
55
  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
+ over stdio** (no `Content-Length` framing). `claude/channel` is the
57
+ convention's wire name.
56
58
 
57
59
  1. **Handshake** — client sends `initialize`; the server's result must
58
60
  declare `capabilities.experimental["claude/channel"]` and may include an
@@ -69,11 +71,13 @@ Claude Code convention, hence the capability name.
69
71
  4. **Shutdown** — SIGTERM on session end (SIGKILL after a grace period).
70
72
 
71
73
  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
+ implements the whole convention dependency-free (the server itself is ~60
75
+ lines; the rest of the file is the demo): append a line to a watched file and
76
+ an idle pi session wakes with it.
74
77
 
75
78
  ```bash
76
- node examples/file-channel.js --demo # prints the wire exchange, no pi needed
79
+ node examples/file-channel.js --demo # narrated walkthrough, no pi needed
80
+ node examples/file-channel.js --demo --wire # the same exchange as raw wire frames
77
81
  ```
78
82
 
79
83
  ## Test
@@ -5,12 +5,14 @@
5
5
  // session as a channel event. Declares one tool (file_channel_status) so the
6
6
  // tool-proxy path is exercised too.
7
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
8
+ // node file-channel.js <path> run as a channel server (pi spawns this)
9
+ // node file-channel.js --demo narrated walkthrough, no pi needed
10
+ // node file-channel.js --demo --wire the same exchange as raw wire frames
10
11
  //
11
12
  // Wire format: newline-delimited JSON-RPC 2.0 over stdio.
12
13
 
13
14
  import { openSync, readSync, fstatSync, watchFile, closeSync } from "node:fs";
15
+ import { setTimeout as sleep } from "node:timers/promises";
14
16
 
15
17
  const NOTIFICATION = "notifications/claude/channel";
16
18
  const CAPABILITY = "claude/channel";
@@ -22,13 +24,69 @@ if (!arg || arg === "--help") {
22
24
  }
23
25
 
24
26
  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" } } });
27
+ // One scripted exchange, printable two ways: --wire shows the literal
28
+ // ndjson frames; the default narrates the same frames as a story.
29
+ const frames = {
30
+ init: { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "channels.tools", version: "0.1.0" } } },
31
+ initResult: { jsonrpc: "2.0", id: 1, result: { capabilities: { experimental: { [CAPABILITY]: {} } }, instructions: "Lines appended to the watched file arrive as channel events." } },
32
+ initialized: { jsonrpc: "2.0", method: "notifications/initialized" },
33
+ toolsList: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
34
+ toolsResult: { jsonrpc: "2.0", id: 2, result: { tools: [{ name: "file_channel_status", description: "Report the watched path and lines delivered.", inputSchema: { type: "object", properties: {} } }] } },
35
+ event1: { jsonrpc: "2.0", method: NOTIFICATION, params: { content: "deploy finished: api v2.4.1 is live", meta: { count: 1, source_path: "inbox.txt" } } },
36
+ event3: { jsonrpc: "2.0", method: NOTIFICATION, params: { content: "tests passed: 214/214\nreview approved: PR #87\nnew issue filed: login flake", meta: { count: 3, source_path: "inbox.txt" } } },
37
+ };
38
+
39
+ if (process.argv.includes("--wire")) {
40
+ for (const [name, frame] of Object.entries(frames)) {
41
+ const dir = frame.method === NOTIFICATION || frame.result ? "->" : "<-";
42
+ console.log(`${dir} ${JSON.stringify(frame)}`);
43
+ void name;
44
+ }
45
+ process.exit(0);
46
+ }
47
+
48
+ const tty = process.stdout.isTTY;
49
+ const paint = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
50
+ const dim = (s) => paint(2, s);
51
+ const cyan = (s) => paint(36, s);
52
+ const bold = (s) => paint(1, s);
53
+ const green = (s) => paint(32, s);
54
+ const rule = (title) => console.log(`\n${dim("──")} ${bold(title)} ${dim("─".repeat(Math.max(2, 56 - title.length)))}`);
55
+ const say = (s) => console.log(s);
56
+ const pause = () => (tty ? sleep(650) : Promise.resolve());
57
+
58
+ say(`${bold("file-channel")} ${dim("·")} a channel server: one file in, channel events out`);
59
+ say(dim("the same exchange as raw wire frames: --demo --wire"));
60
+
61
+ rule("1 · handshake — the session spawns this server");
62
+ await pause();
63
+ say(` ${cyan("→")} initialize`);
64
+ say(` ${cyan("←")} capabilities.experimental[${green('"claude/channel"')}] ${green("✓")}`);
65
+ say(` ${dim("instructions:")} "Lines appended to the watched file arrive as channel events."`);
66
+ await pause();
67
+ say(` ${cyan("→")} tools/list`);
68
+ say(` ${cyan("←")} 1 tool: ${bold("file_channel_status")} ${dim("— registered with the agent, callable by name")}`);
69
+
70
+ rule("2 · something happens on your machine");
71
+ await pause();
72
+ say(` ${dim("$")} echo ${green('"deploy finished: api v2.4.1 is live"')} >> inbox.txt`);
73
+ await pause();
74
+ say(` ${cyan("←")} notifications/claude/channel ${dim("(count 1, source inbox.txt)")}`);
75
+ say(` ${bold("deploy finished: api v2.4.1 is live")}`);
76
+ say(` ${dim("the session is idle — this push wakes it and starts a turn")}`);
77
+
78
+ rule("3 · three more land while the agent is busy");
79
+ await pause();
80
+ say(` ${dim("$")} echo ... >> inbox.txt ${dim("×3, mid-turn")}`);
81
+ await pause();
82
+ say(` ${cyan("←")} notifications/claude/channel ${dim("(count 3 — one push, coalesced)")}`);
83
+ for (const line of frames.event3.params.content.split("\n")) say(` ${bold(line)}`);
84
+ say(` ${dim("buffered while the turn ran; delivered together when it settled")}`);
85
+
86
+ rule("try it live");
87
+ say(` add to ${cyan("~/.pi/agent/channels.json")}:`);
88
+ say(` { "channelServers": { "file": { "command": "node", "args": ["${dim("/path/to/")}file-channel.js", "inbox.txt"] } } }`);
89
+ say(` then, with the session idle: ${dim("$")} echo hi >> inbox.txt ${dim("— and watch it wake")}\n`);
32
90
  process.exit(0);
33
91
  }
34
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channels.tools",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { splitGuidance, renderBatch, strictestIntent } from "./envelope.ts";
3
+ import { splitGuidance, renderBatch, renderSummary, strictestIntent } from "./envelope.ts";
4
4
 
5
5
  test("splitGuidance separates on the first --- sentinel", () => {
6
6
  const r = splitGuidance("line one\n\n---\nGUIDANCE HERE");
@@ -130,3 +130,29 @@ test("events without a server count fall back to counting notifications", () =>
130
130
  ]);
131
131
  assert.match(out, /count="2"/);
132
132
  });
133
+
134
+ test("renderSummary takes each event's first body line and drops the guidance", () => {
135
+ const out = renderSummary("muster-channel", [
136
+ { source: "muster-channel", content: 'muster: reply on thread #1 "x"\n\n---\ncall get_thread 1', meta: {} },
137
+ { source: "muster-channel", content: 'muster: fyi on thread #2 "y"\n\n---\nread only', meta: {} },
138
+ ]);
139
+ assert.equal(out, 'muster: reply on thread #1 "x"\nmuster: fyi on thread #2 "y"');
140
+ });
141
+
142
+ test("renderSummary dedupes identical lines and caps at five with a remainder", () => {
143
+ const events = Array.from({ length: 8 }, (_, i) => ({
144
+ source: "s",
145
+ content: `line ${i}`,
146
+ meta: {},
147
+ }));
148
+ events.push({ source: "s", content: "line 0", meta: {} });
149
+ const out = renderSummary("s", events);
150
+ const lines = out.split("\n");
151
+ assert.equal(lines.length, 6);
152
+ assert.equal(lines[5], "…and 3 more");
153
+ });
154
+
155
+ test("renderSummary falls back to naming the source when no event has a body", () => {
156
+ const out = renderSummary("galley", [{ source: "galley", content: "\n\n---\nguidance only", meta: {} }]);
157
+ assert.equal(out, "[galley] new channel activity");
158
+ });
package/src/envelope.ts CHANGED
@@ -43,6 +43,25 @@ function escapeAttr(value: string): string {
43
43
  .replace(/"/g, "&quot;");
44
44
  }
45
45
 
46
+ // renderSummary is the VISIBLE half of a delivery: one short line per event
47
+ // (the first body line, which channel servers author as their own compact
48
+ // summary), deduplicated, capped at five. It is what the operator reads in
49
+ // the transcript, and what the model acts on in the rare race where the
50
+ // envelope's nextTurn queue drains only at a later prompt — so it must be
51
+ // able to stand alone, not just decorate.
52
+ export function renderSummary(source: string, events: ChannelEvent[]): string {
53
+ const lines: string[] = [];
54
+ for (const event of events) {
55
+ const { body } = splitGuidance(event.content);
56
+ const first = body.split("\n").find((line) => line.trim() !== "");
57
+ if (first !== undefined && !lines.includes(first)) lines.push(first);
58
+ }
59
+ if (lines.length === 0) return `[${source}] new channel activity`;
60
+ const shown = lines.slice(0, 5);
61
+ if (lines.length > shown.length) shown.push(`…and ${lines.length - shown.length} more`);
62
+ return shown.join("\n");
63
+ }
64
+
46
65
  export function renderBatch(source: string, events: ChannelEvent[]): string {
47
66
  const bodies: string[] = [];
48
67
  const guidance: string[] = [];
package/src/index.test.ts CHANGED
@@ -15,6 +15,7 @@ type ToolDef = { name: string; execute(id: string, params: unknown): Promise<unk
15
15
  function fakePi() {
16
16
  const handlers = new Map<string, Handler[]>();
17
17
  const sent: Array<{ msg: unknown; opts: unknown }> = [];
18
+ const customs: Array<{ msg: unknown; opts: unknown }> = [];
18
19
  const registered: string[] = [];
19
20
  const unregistered: string[] = [];
20
21
  const tools = new Map<string, ToolDef>();
@@ -26,6 +27,7 @@ function fakePi() {
26
27
  handlers.set(event, list);
27
28
  },
28
29
  sendUserMessage(content: unknown, opts: unknown) { sent.push({ msg: content, opts }); },
30
+ sendMessage(message: unknown, opts: unknown) { customs.push({ msg: message, opts }); },
29
31
  registerTool(def: ToolDef) { registered.push(def.name); tools.set(def.name, def); },
30
32
  unregisterTool(name: string) { unregistered.push(name); tools.delete(name); },
31
33
  getAllTools() { return [{ name: "bash" }, { name: "read" }]; },
@@ -43,7 +45,7 @@ function fakePi() {
43
45
  for (const h of handlers.get(event) ?? []) out.push(await h(payload, ctx));
44
46
  return out;
45
47
  }
46
- return { pi, ctx, fire, sent, registered, unregistered, tools, statuses };
48
+ return { pi, ctx, fire, sent, customs, registered, unregistered, tools, statuses };
47
49
  }
48
50
 
49
51
  test("session_start sets AGENT_SESSION_ID before any server is spawned", async () => {
@@ -63,19 +65,29 @@ test("session_start sets AGENT_SESSION_ID before any server is spawned", async (
63
65
  await fire("session_shutdown");
64
66
  });
65
67
 
66
- test("an inbound event while idle is delivered as a user message through the full prompt pipeline", async () => {
67
- const { pi, fire, sent } = fakePi();
68
+ test("an inbound event delivers a hidden envelope plus a visible summary line", async () => {
69
+ const { pi, fire, sent, customs } = fakePi();
68
70
  createExtension(pi as never, {
69
71
  env: {},
70
72
  loadConfig: () => ({ fake: { command: process.execPath, args: [FAKE], env: { FAKE_PUSH_AFTER_MS: "50" } } }),
71
73
  });
72
74
  await fire("session_start", { reason: "startup" });
73
75
  await new Promise((r) => setTimeout(r, 500));
76
+ // The envelope rides a hidden custom message queued for the next turn —
77
+ // pi injects the nextTurn queue before before_agent_start fires.
78
+ assert.equal(customs.length, 1);
79
+ const custom = customs[0].msg as { customType: string; content: string; display: boolean };
80
+ assert.equal(custom.customType, "channel-envelope");
81
+ assert.equal(custom.display, false);
82
+ assert.match(custom.content, /<channel source="fake"/);
83
+ assert.equal((customs[0].opts as { deliverAs?: string }).deliverAs, "nextTurn");
84
+ // The visible user message is the short summary — no raw XML — and it is
85
+ // what triggers the turn through the full prompt pipeline.
74
86
  assert.equal(sent.length, 1);
75
87
  const opts = sent[0].opts as { deliverAs?: string };
76
88
  assert.equal(opts.deliverAs, "steer");
77
89
  const text = sent[0].msg as string;
78
- assert.match(text, /<channel source="fake"/);
90
+ assert.doesNotMatch(text, /<channel/);
79
91
  await fire("session_shutdown");
80
92
  });
81
93
 
package/src/index.ts CHANGED
@@ -43,14 +43,29 @@ export function createExtension(pi: any, deps: Deps = {}): void {
43
43
  });
44
44
 
45
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
46
+ deliver: ({ envelope, summary }) => {
47
+ // Two-part delivery so the transcript shows a short readable line
48
+ // instead of the raw XML envelope, while the model still receives the
49
+ // envelope verbatim (pi serializes custom messages to the LLM as user
50
+ // content; display:false only hides them from the TUI).
51
+ //
52
+ // Why not one custom message with triggerTurn: that path starts the
53
+ // turn without emitting before_agent_start (verified against pi
54
+ // 0.84.3 dist), so extensions that assemble or capture the system
50
55
  // 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" });
56
+ // session whose first turn is a channel wake. Instead the envelope is
57
+ // queued deliverAs:"nextTurn" and the turn is triggered by the
58
+ // summary via sendUserMessage: prompt() drains the nextTurn queue
59
+ // into that same turn BEFORE emitting before_agent_start. Wake only
60
+ // delivers while the agent is settled; if a turn starts in the narrow
61
+ // race window, the summary steers into it and the envelope waits for
62
+ // the next prompt — degraded (the summary stands alone by design),
63
+ // never lost.
64
+ pi.sendMessage(
65
+ { customType: "channel-envelope", content: envelope, display: false },
66
+ { deliverAs: "nextTurn" },
67
+ );
68
+ pi.sendUserMessage(summary, { deliverAs: "steer" });
54
69
  },
55
70
  log: (message) => log(message),
56
71
  });
package/src/wake.test.ts CHANGED
@@ -5,7 +5,7 @@ import type { ChannelEvent } from "./envelope.ts";
5
5
 
6
6
  function harness() {
7
7
  const delivered: string[] = [];
8
- const wake = createWake({ deliver: (text) => delivered.push(text) });
8
+ const wake = createWake({ deliver: (d) => delivered.push(d.envelope) });
9
9
  wake.onSessionStart();
10
10
  wake.onReady();
11
11
  return { wake, delivered };
@@ -61,7 +61,7 @@ test("settling with an empty buffer delivers nothing", () => {
61
61
 
62
62
  test("mail arriving before ready is buffered and released once ready", () => {
63
63
  const delivered: string[] = [];
64
- const wake = createWake({ deliver: (text) => delivered.push(text) });
64
+ const wake = createWake({ deliver: (d) => delivered.push(d.envelope) });
65
65
  wake.onSessionStart();
66
66
  wake.onEvent(ev("muster-channel", "early mail"));
67
67
  assert.equal(delivered.length, 0, "must not deliver into a half-built session");
@@ -94,9 +94,9 @@ test("a throwing deliver for one source still delivers the other (F2)", () => {
94
94
  const delivered: string[] = [];
95
95
  const logs: string[] = [];
96
96
  const wake = createWake({
97
- deliver: (text) => {
98
- if (text.includes('source="bad"')) throw new Error("boom: deliver exploded");
99
- delivered.push(text);
97
+ deliver: (d) => {
98
+ if (d.envelope.includes('source="bad"')) throw new Error("boom: deliver exploded");
99
+ delivered.push(d.envelope);
100
100
  },
101
101
  log: (m) => logs.push(m),
102
102
  });
package/src/wake.ts CHANGED
@@ -1,6 +1,13 @@
1
- import { renderBatch } from "./envelope.ts";
1
+ import { renderBatch, renderSummary } from "./envelope.ts";
2
2
  import type { ChannelEvent } from "./envelope.ts";
3
3
 
4
+ export type Delivery = {
5
+ // The model-facing XML envelope — full bodies, guidance, meta attributes.
6
+ envelope: string;
7
+ // The human-facing short form — what the transcript shows.
8
+ summary: string;
9
+ };
10
+
4
11
  export type Wake = {
5
12
  onEvent(event: ChannelEvent): void;
6
13
  onAgentStart(): void;
@@ -10,7 +17,7 @@ export type Wake = {
10
17
  pendingCount(): number;
11
18
  };
12
19
 
13
- export function createWake(opts: { deliver: (text: string) => void; log?: (message: string) => void }): Wake {
20
+ export function createWake(opts: { deliver: (delivery: Delivery) => void; log?: (message: string) => void }): Wake {
14
21
  // Insertion order of this Map is the delivery order across sources.
15
22
  let buffer = new Map<string, ChannelEvent[]>();
16
23
  let busy = false;
@@ -29,7 +36,7 @@ export function createWake(opts: { deliver: (text: string) => void; log?: (messa
29
36
  for (const [source, events] of batches) {
30
37
  if (events.length === 0) continue;
31
38
  try {
32
- opts.deliver(renderBatch(source, events));
39
+ opts.deliver({ envelope: renderBatch(source, events), summary: renderSummary(source, events) });
33
40
  } catch (error) {
34
41
  // This runs synchronously inside the OS stream callback chain
35
42
  // (stdout 'data' -> dispatch -> onEvent -> flush -> deliver ->