tandem-editor 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "tandem-editor",
3
+ "owner": {
4
+ "name": "Tandem"
5
+ },
6
+ "metadata": {
7
+ "description": "Tandem — collaborative AI-human document editor"
8
+ },
9
+ "plugins": [
10
+ {
11
+ "name": "tandem",
12
+ "source": {
13
+ "source": "github",
14
+ "repo": "bloknayrb/tandem"
15
+ },
16
+ "description": "Edit and iterate on documents with Claude — no copy-paste, real-time push via plugin monitor"
17
+ }
18
+ ]
19
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "tandem",
3
+ "version": "0.6.0",
4
+ "description": "Edit and iterate on documents with Claude — no copy-paste, real-time push via plugin monitor",
5
+ "author": {
6
+ "name": "Tandem"
7
+ },
8
+ "repository": "https://github.com/bloknayrb/tandem",
9
+ "license": "MIT",
10
+ "keywords": ["editor", "collaborative", "mcp", "claude"],
11
+ "mcpServers": {
12
+ "tandem": {
13
+ "command": "npx",
14
+ "args": ["-y", "tandem-editor", "mcp-stdio"],
15
+ "env": {
16
+ "TANDEM_URL": "http://localhost:3479"
17
+ }
18
+ },
19
+ "tandem-channel": {
20
+ "command": "npx",
21
+ "args": ["-y", "tandem-editor", "channel"],
22
+ "env": {
23
+ "TANDEM_URL": "http://localhost:3479"
24
+ }
25
+ }
26
+ }
27
+ }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,61 @@ All notable changes to Tandem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.6.0] - 2026-04-15
11
+
12
+ ### Added
13
+
14
+ - **Plugin bridge to Cowork** — new `tandem mcp-stdio` subcommand is a stdio ↔ HTTP JSON-RPC proxy so Claude Desktop's plugin loader surfaces the full `tandem_*` tool surface into Cowork VM sessions. Verified empirically that plugin-loaded HTTP MCP entries don't bridge to Cowork but plugin-loaded stdio entries do. The plugin's `.claude-plugin/plugin.json` now declares stdio entries for both `tandem` (proxy) and `tandem-channel` (existing shim re-exposed as `tandem channel` subcommand), invoked via `npx -y tandem-editor …` so the plugin cache never needs dev dependencies. Both subcommands share a strict preflight in `src/cli/preflight.ts` that fails fast with a single clear message when the Tandem server isn't running on `localhost:3479`.
15
+ - `tandem channel` CLI subcommand — npm-delivered entry for the plugin's `tandem-channel` MCP server; shares runtime with the standalone `src/channel/index.ts` binary via the new `src/channel/run.ts` extraction.
16
+ - **Settings expansion** — Settings popover grows from layout/dwell/authorship into a fuller preferences surface:
17
+ - Ctrl+, / Cmd+, hotkey (AZERTY/QWERTZ/IME-safe, survives non-QWERTY layouts)
18
+ - Display Name field, synced live with the StatusBar via a shared `useUserName` hook
19
+ - Reduce Motion toggle (JS-gates all autoscroll paths; defaults to `prefers-reduced-motion`)
20
+ - Text Size S/M/L for editor reading density (browser zoom remains the WCAG 1.4.4 path)
21
+ - Theme Light/Dark/System (CSS custom-property token system on `<html data-theme>` with `forced-colors` support for Windows High Contrast)
22
+ - Tier 0 accessibility prerequisites on SettingsPopover: `role="dialog"` + `aria-modal`, focus trap, Escape-to-close, pointerdown outside-dismiss, radiogroup semantics, 24×24 hit targets, focus-return on close
23
+
24
+ ### Changed
25
+
26
+ - Repo's project-level `.mcp.json` renamed to `.mcp.json.example` and gitignored so it no longer ships inside plugin installs. The plugin's own `.claude-plugin/plugin.json` is authoritative for MCP wiring. Developers who clone the repo should copy the example to `.mcp.json` (gitignored) if they want Claude Code to auto-connect locally.
27
+ - Settings heading renamed "Layout Settings" → "Settings"
28
+ - Settings popover hardcoded hex values swapped to CSS tokens (remaining components will migrate in a follow-up)
29
+ - `shutdownForTests` renamed to `shutdownMonitor` (test-only alias kept for backward compatibility)
30
+ - `refreshMode` IIFE now wrapped in an outer `.catch` to keep future synchronous throws off the hot path
31
+
32
+ ### Fixed
33
+
34
+ - **Monitor preserves last-known `documentId`** — doc-less events (e.g. `chat:message`) no longer blank out the tracked document, so the shutdown awareness clear always targets a valid document.
35
+ - **Monitor exits 1 on shutdown awareness failure** — if the final `clearAwareness` POST fails during SIGINT/SIGTERM, the monitor exits with a non-zero status rather than silently succeeding.
36
+ - **`/api/setup` returns accurate status codes** — 207 on partial failure (some targets configured, some failed) and 500 on total failure, instead of always returning 200.
37
+ - **Checkpoint after stdout write** — `lastEventId` is only advanced after `process.stdout.write` returns, so EPIPE on a closed pipe no longer silently skips an event on reconnect.
38
+ - **Async EPIPE surfaces as exit(1)** — `process.stdout.on('error')` listener now catches asynchronous EPIPE (plugin host closes pipe mid-stream); monitor exits 1 instead of silently advancing `lastEventId` past lost events.
39
+ - **Defensive exit on monitor fallthrough** — the retry loop exits 1 if it ever terminates without hitting the explicit exhaustion path.
40
+
41
+ ## [0.5.1] - 2026-04-13
42
+
43
+ ### Added
44
+
45
+ - **Claude Code plugin support** — monitor-based event push (`src/monitor/index.ts`) gives real-time notifications without polling or the channel shim. Install via `claude plugin marketplace add bloknayrb/tandem`.
46
+ - **`--with-channel-shim` opt-in** — `tandem setup --with-channel-shim` writes the legacy `tandem-channel` MCP entry for setups that can't install the plugin.
47
+
48
+ ### Changed
49
+
50
+ - `tandem setup` no longer writes the `tandem-channel` MCP entry by default — running the plugin and the shim simultaneously produces duplicate event notifications. The shim is now opt-in only via `--with-channel-shim`.
51
+
52
+ ### Fixed
53
+
54
+ - **Windows update failure** — sidecar is now killed before the NSIS installer runs, preventing "Error opening file for writing: node-sidecar.exe" during updates
55
+ - **Mode check fails closed** — `/api/mode` errors now fall back to "solo" at startup (privacy signal, not a permissive default) while the hot-path background refresh keeps the last known good value to avoid mid-session suppression.
56
+ - **Retry counter resets on stable uptime** — retry count now resets only after 60s of continuous uptime, not on every delivered event; prevents infinite reconnect loops when the server crashes after each event.
57
+ - **Exponential backoff on reconnect** — monitor reconnects use 2s/4s/8s/16s/30s backoff instead of a fixed 2s delay.
58
+ - **SIGINT/SIGTERM clears awareness** — monitor posts a final `clearAwareness` before exit so the "Claude is active" indicator doesn't hang in the browser.
59
+ - **Per-route fetch timeouts** — `AbortSignal.timeout` enforces budgets per route (connect 10s, mode 2s, awareness 5s, error report 3s) to prevent hung SSE connects or mode lookups from stalling the monitor.
60
+ - **SSE parse errors don't advance `lastEventId`** — JSON parse failures and schema validation errors are logged with event ID + frame tail but do not advance `lastEventId`, so bad events are re-delivered on reconnect rather than silently dropped.
61
+ - **SKILL.md corrected** — `question` annotation guidance now uses `type === 'comment' && directedAt === 'claude' && author === 'user'`; all 5 highlight colors listed (yellow, red, green, blue, purple).
62
+
8
63
  ## [0.5.0] - 2026-04-13
9
64
 
10
65
  ### Added
package/README.md CHANGED
@@ -34,6 +34,33 @@ tandem # starts server + opens browser
34
34
 
35
35
  `tandem setup` auto-detects Claude Code and Claude Desktop, writes MCP configuration, and installs a skill (`~/.claude/skills/tandem/SKILL.md`) that teaches Claude how to use Tandem's tools effectively. Re-run after upgrading (`npm update -g tandem-editor && tandem setup`).
36
36
 
37
+ ### Quickstart: Claude Code plugin (recommended)
38
+
39
+ Install the plugin to expose Tandem's tools and real-time event stream into Claude Desktop chats **and** Cowork VM sessions:
40
+
41
+ ```bash
42
+ claude plugin marketplace add bloknayrb/tandem
43
+ claude plugin install tandem@tandem-editor
44
+ ```
45
+
46
+ **Tandem must be running on the host before the plugin can do anything.** The plugin spawns two stdio MCP processes (`tandem mcp-stdio` and `tandem channel`) that proxy to `http://localhost:3479`. If the server isn't up they fail fast and log "Tandem server not reachable at …". Start the Tauri desktop app or run `tandem start` on the host first, then open Claude.
47
+
48
+ ### Legacy stdio channel shim
49
+
50
+ If you can't install the plugin, use the older channel shim:
51
+
52
+ ```bash
53
+ tandem setup --with-channel-shim
54
+ ```
55
+
56
+ This writes a `tandem-channel` entry to your Claude Code MCP config. Start Claude Code with:
57
+
58
+ ```bash
59
+ claude --dangerously-load-development-channels server:tandem-channel
60
+ ```
61
+
62
+ Don't combine this with the plugin — both subscribe to `/api/events` and you'll get duplicate notifications for every event.
63
+
37
64
  ### Connect Claude Code
38
65
 
39
66
  For the full Tandem experience, start Claude Code with the **channel push** flag:
@@ -6799,7 +6799,7 @@ var require_dist = __commonJS({
6799
6799
  }
6800
6800
  });
6801
6801
 
6802
- // src/channel/index.ts
6802
+ // src/channel/run.ts
6803
6803
  import { createConnection } from "net";
6804
6804
 
6805
6805
  // node_modules/zod/v3/external.js
@@ -17915,6 +17915,17 @@ var SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
17915
17915
  var CHANNEL_MAX_RETRIES = 5;
17916
17916
  var CHANNEL_RETRY_DELAY_MS = 2e3;
17917
17917
 
17918
+ // src/shared/cli-runtime.ts
17919
+ function redirectConsoleToStderr() {
17920
+ console.log = console.error;
17921
+ console.warn = console.error;
17922
+ console.info = console.error;
17923
+ }
17924
+ function resolveTandemUrl(override) {
17925
+ const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
17926
+ return raw.replace(/\/$/, "");
17927
+ }
17928
+
17918
17929
  // src/server/events/types.ts
17919
17930
  var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
17920
17931
  "annotation:created",
@@ -18013,12 +18024,12 @@ function formatEventMeta(event) {
18013
18024
  // src/channel/event-bridge.ts
18014
18025
  var AWARENESS_DEBOUNCE_MS = 500;
18015
18026
  var MODE_CACHE_TTL_MS = 2e3;
18016
- async function startEventBridge(mcp2, tandemUrl) {
18027
+ async function startEventBridge(mcp, tandemUrl) {
18017
18028
  let retries = 0;
18018
18029
  let lastEventId;
18019
18030
  while (retries < CHANNEL_MAX_RETRIES) {
18020
18031
  try {
18021
- await connectAndStream(mcp2, tandemUrl, lastEventId, (id) => {
18032
+ await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
18022
18033
  lastEventId = id;
18023
18034
  retries = 0;
18024
18035
  });
@@ -18051,7 +18062,7 @@ async function startEventBridge(mcp2, tandemUrl) {
18051
18062
  }
18052
18063
  }
18053
18064
  }
18054
- async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
18065
+ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
18055
18066
  const headers = { Accept: "text/event-stream" };
18056
18067
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
18057
18068
  const res = await fetch(`${tandemUrl}/api/events`, { headers });
@@ -18136,7 +18147,7 @@ async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
18136
18147
  }
18137
18148
  if (eventId) onEventId(eventId);
18138
18149
  try {
18139
- await mcp2.notification({
18150
+ await mcp.notification({
18140
18151
  method: "notifications/claude/channel",
18141
18152
  params: {
18142
18153
  content: formatEventContent(event),
@@ -18175,11 +18186,143 @@ async function getCachedMode(tandemUrl) {
18175
18186
  return cachedMode;
18176
18187
  }
18177
18188
 
18178
- // src/channel/index.ts
18179
- console.log = console.error;
18180
- console.warn = console.error;
18181
- console.info = console.error;
18182
- var TANDEM_URL = process.env.TANDEM_URL || "http://localhost:3479";
18189
+ // src/channel/run.ts
18190
+ async function runChannel(opts = {}) {
18191
+ redirectConsoleToStderr();
18192
+ const tandemUrl = resolveTandemUrl();
18193
+ const mcp = new Server(
18194
+ { name: "tandem-channel", version: "0.1.0" },
18195
+ {
18196
+ capabilities: {
18197
+ experimental: {
18198
+ "claude/channel": {},
18199
+ "claude/channel/permission": {}
18200
+ },
18201
+ tools: {}
18202
+ },
18203
+ instructions: [
18204
+ 'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
18205
+ "These are real-time push notifications of user actions in the collaborative document editor.",
18206
+ "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
18207
+ "chat:message, document:opened, document:closed, document:switched.",
18208
+ "Chat messages may include a 'selection' field with buffered selection context.",
18209
+ "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
18210
+ "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18211
+ "Do not reply to non-chat events \u2014 just act on them using tools.",
18212
+ "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
18213
+ ].join(" ")
18214
+ }
18215
+ );
18216
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
18217
+ tools: [
18218
+ {
18219
+ name: "tandem_reply",
18220
+ description: "Reply to a chat message in Tandem",
18221
+ inputSchema: {
18222
+ type: "object",
18223
+ properties: {
18224
+ text: { type: "string", description: "The reply message" },
18225
+ documentId: {
18226
+ type: "string",
18227
+ description: "Document ID from the channel event (optional)"
18228
+ },
18229
+ replyTo: {
18230
+ type: "string",
18231
+ description: "Message ID being replied to (optional)"
18232
+ }
18233
+ },
18234
+ required: ["text"]
18235
+ }
18236
+ }
18237
+ ]
18238
+ }));
18239
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
18240
+ if (req.params.name === "tandem_reply") {
18241
+ const args = req.params.arguments;
18242
+ try {
18243
+ const res = await fetch(`${tandemUrl}/api/channel-reply`, {
18244
+ method: "POST",
18245
+ headers: { "Content-Type": "application/json" },
18246
+ body: JSON.stringify(args)
18247
+ });
18248
+ let data;
18249
+ try {
18250
+ data = await res.json();
18251
+ } catch {
18252
+ data = { message: "Non-JSON response" };
18253
+ }
18254
+ if (!res.ok) {
18255
+ return {
18256
+ content: [
18257
+ {
18258
+ type: "text",
18259
+ text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
18260
+ }
18261
+ ],
18262
+ isError: true
18263
+ };
18264
+ }
18265
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
18266
+ } catch (err) {
18267
+ return {
18268
+ content: [
18269
+ {
18270
+ type: "text",
18271
+ text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
18272
+ }
18273
+ ],
18274
+ isError: true
18275
+ };
18276
+ }
18277
+ }
18278
+ throw new Error(`Unknown tool: ${req.params.name}`);
18279
+ });
18280
+ const PermissionRequestSchema = external_exports.object({
18281
+ method: external_exports.literal("notifications/claude/channel/permission_request"),
18282
+ params: external_exports.object({
18283
+ request_id: external_exports.string(),
18284
+ tool_name: external_exports.string(),
18285
+ description: external_exports.string(),
18286
+ input_preview: external_exports.string()
18287
+ })
18288
+ });
18289
+ mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18290
+ try {
18291
+ const res = await fetch(`${tandemUrl}/api/channel-permission`, {
18292
+ method: "POST",
18293
+ headers: { "Content-Type": "application/json" },
18294
+ body: JSON.stringify({
18295
+ requestId: params.request_id,
18296
+ toolName: params.tool_name,
18297
+ description: params.description,
18298
+ inputPreview: params.input_preview
18299
+ })
18300
+ });
18301
+ if (!res.ok) {
18302
+ console.error(
18303
+ `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18304
+ );
18305
+ }
18306
+ } catch (err) {
18307
+ console.error("[Channel] Failed to forward permission request:", err);
18308
+ }
18309
+ });
18310
+ console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);
18311
+ if (!opts.skipReachabilityLog) {
18312
+ const reachable = await checkServerReachable(tandemUrl);
18313
+ if (!reachable) {
18314
+ console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);
18315
+ console.error("[Channel] Start it with: tandem start");
18316
+ }
18317
+ }
18318
+ const transport = new StdioServerTransport();
18319
+ await mcp.connect(transport);
18320
+ console.error("[Channel] Connected to Claude Code via stdio");
18321
+ startEventBridge(mcp, tandemUrl).catch((err) => {
18322
+ console.error("[Channel] Event bridge failed unexpectedly:", err);
18323
+ process.exit(1);
18324
+ });
18325
+ }
18183
18326
  async function checkServerReachable(url, timeoutMs = 2e3) {
18184
18327
  let parsed;
18185
18328
  try {
@@ -18208,139 +18351,9 @@ async function checkServerReachable(url, timeoutMs = 2e3) {
18208
18351
  });
18209
18352
  });
18210
18353
  }
18211
- var mcp = new Server(
18212
- { name: "tandem-channel", version: "0.1.0" },
18213
- {
18214
- capabilities: {
18215
- experimental: {
18216
- "claude/channel": {},
18217
- "claude/channel/permission": {}
18218
- },
18219
- tools: {}
18220
- },
18221
- instructions: [
18222
- 'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
18223
- "These are real-time push notifications of user actions in the collaborative document editor.",
18224
- "Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
18225
- "chat:message, document:opened, document:closed, document:switched.",
18226
- "Chat messages may include a 'selection' field with buffered selection context.",
18227
- "Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
18228
- "Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
18229
- "Do not reply to non-chat events \u2014 just act on them using tools.",
18230
- "If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
18231
- ].join(" ")
18232
- }
18233
- );
18234
- mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
18235
- tools: [
18236
- {
18237
- name: "tandem_reply",
18238
- description: "Reply to a chat message in Tandem",
18239
- inputSchema: {
18240
- type: "object",
18241
- properties: {
18242
- text: { type: "string", description: "The reply message" },
18243
- documentId: {
18244
- type: "string",
18245
- description: "Document ID from the channel event (optional)"
18246
- },
18247
- replyTo: {
18248
- type: "string",
18249
- description: "Message ID being replied to (optional)"
18250
- }
18251
- },
18252
- required: ["text"]
18253
- }
18254
- }
18255
- ]
18256
- }));
18257
- mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
18258
- if (req.params.name === "tandem_reply") {
18259
- const args = req.params.arguments;
18260
- try {
18261
- const res = await fetch(`${TANDEM_URL}/api/channel-reply`, {
18262
- method: "POST",
18263
- headers: { "Content-Type": "application/json" },
18264
- body: JSON.stringify(args)
18265
- });
18266
- let data;
18267
- try {
18268
- data = await res.json();
18269
- } catch {
18270
- data = { message: "Non-JSON response" };
18271
- }
18272
- if (!res.ok) {
18273
- return {
18274
- content: [
18275
- {
18276
- type: "text",
18277
- text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
18278
- }
18279
- ],
18280
- isError: true
18281
- };
18282
- }
18283
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
18284
- } catch (err) {
18285
- return {
18286
- content: [
18287
- {
18288
- type: "text",
18289
- text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
18290
- }
18291
- ],
18292
- isError: true
18293
- };
18294
- }
18295
- }
18296
- throw new Error(`Unknown tool: ${req.params.name}`);
18297
- });
18298
- var PermissionRequestSchema = external_exports.object({
18299
- method: external_exports.literal("notifications/claude/channel/permission_request"),
18300
- params: external_exports.object({
18301
- request_id: external_exports.string(),
18302
- tool_name: external_exports.string(),
18303
- description: external_exports.string(),
18304
- input_preview: external_exports.string()
18305
- })
18306
- });
18307
- mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
18308
- try {
18309
- const res = await fetch(`${TANDEM_URL}/api/channel-permission`, {
18310
- method: "POST",
18311
- headers: { "Content-Type": "application/json" },
18312
- body: JSON.stringify({
18313
- requestId: params.request_id,
18314
- toolName: params.tool_name,
18315
- description: params.description,
18316
- inputPreview: params.input_preview
18317
- })
18318
- });
18319
- if (!res.ok) {
18320
- console.error(
18321
- `[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
18322
- );
18323
- }
18324
- } catch (err) {
18325
- console.error("[Channel] Failed to forward permission request:", err);
18326
- }
18327
- });
18328
- async function main() {
18329
- console.error(`[Channel] Tandem channel shim starting (server: ${TANDEM_URL})`);
18330
- const reachable = await checkServerReachable(TANDEM_URL);
18331
- if (!reachable) {
18332
- console.error(`[Channel] Cannot reach Tandem server at ${TANDEM_URL}`);
18333
- console.error("[Channel] Start it with: npm run dev:standalone");
18334
- }
18335
- const transport = new StdioServerTransport();
18336
- await mcp.connect(transport);
18337
- console.error("[Channel] Connected to Claude Code via stdio");
18338
- startEventBridge(mcp, TANDEM_URL).catch((err) => {
18339
- console.error("[Channel] Event bridge failed unexpectedly:", err);
18340
- process.exit(1);
18341
- });
18342
- }
18343
- main().catch((err) => {
18354
+
18355
+ // src/channel/index.ts
18356
+ runChannel().catch((err) => {
18344
18357
  console.error("[Channel] Fatal error:", err);
18345
18358
  process.exit(1);
18346
18359
  });