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 +13 -9
- package/examples/file-channel.js +67 -9
- package/package.json +1 -1
- package/src/envelope.test.ts +27 -1
- package/src/envelope.ts +19 -0
- package/src/index.test.ts +16 -4
- package/src/index.ts +22 -7
- package/src/wake.test.ts +5 -5
- package/src/wake.ts +10 -3
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
|
|
5
|
-
|
|
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
|
|
10
|
-
can speak the (simple) protocol below can drive an agent
|
|
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).
|
|
55
|
-
|
|
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
|
|
73
|
-
|
|
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
|
|
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
|
package/examples/file-channel.js
CHANGED
|
@@ -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>
|
|
9
|
-
// node file-channel.js --demo
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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",
|
package/src/envelope.test.ts
CHANGED
|
@@ -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, """);
|
|
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
|
|
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.
|
|
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: (
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// (pi
|
|
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.
|
|
52
|
-
//
|
|
53
|
-
|
|
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: (
|
|
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: (
|
|
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: (
|
|
98
|
-
if (
|
|
99
|
-
delivered.push(
|
|
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: (
|
|
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 ->
|