omni-notify-mcp 1.3.24 → 1.3.34

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
@@ -28,11 +28,13 @@
28
28
 
29
29
  | Process | Entry | What it owns |
30
30
  |---|---|---|
31
- | **UI/HTTP server** | `dist/ui/server.js` (bin `omni-notify-ui`) | Every channel implementation, the routing policy (DND / idle / mute), the inbound inbox, the Telegram long-poll listener, the Slack channel poller, the built-in ntfy server, the web config UI, and — when `ENABLE_MCP=1` — the Streamable-HTTP `/mcp` endpoint. Listens on `0.0.0.0:3737` by default. |
31
+ | **UI/HTTP server** | `dist/ui/server.js` (bin `omni-notify-ui`) | Every channel implementation, the routing policy (DND / idle / mute), the inbound inbox, the Telegram long-poll listener, the Slack channel poller, the built-in ntfy server, the web config UI, and — when `ENABLE_MCP=1` — the Streamable-HTTP `/mcp` endpoint. Listens on port 3737 on every interface, IPv4 and IPv6, by default. |
32
32
  | **stdio MCP bridge** | `dist/index.js` (bin `omni-notify-mcp`) | A stateless proxy. Auto-spawns the server if `:3737` isn't answering, opens one persistent `/mcp` session, forwards every tool call to it, subscribes to `/api/inbox/stream` and re-emits each message as a `notifications/claude/channel` event. Holds no config and no queue. |
33
33
 
34
34
  The VS Code extension (`vscode-extension/`) is a third, optional face: it starts the
35
- server if needed and renders `http://localhost:3737/` inside a webview.
35
+ server if needed, renders `http://localhost:3737/` inside a webview, reports on its status
36
+ bar whether the server can actually deliver, and writes the stdio bridge into both MCP
37
+ host config files (`~/.claude.json` and the open folder's `.vscode/mcp.json`).
36
38
 
37
39
  > **Build note.** `package.json` is deliberately untracked in this repo (see
38
40
  > `.gitignore`), so the dependency list, the `bin` map, the version and the extension
@@ -77,7 +79,7 @@ entirely server-side.
77
79
  | `ask` | `question`, `timeout_seconds` (30–3600, default 300) | Sends the question to Telegram (every configured chat id) and email (with a one-shot `/reply/:token` web page), then **blocks** until a reply lands or the timeout throws. |
78
80
  | `poll` | — | Drains queued inbox messages for this session's tag; returns `inbox:empty` when there are none. |
79
81
  | `wait_for_inbox` | `timeout_seconds` (5–55, default 50) | Parks a waiter and returns the moment a matching message arrives, as a tool *result*. Returns already-queued messages immediately without parking. |
80
- | `get_idle_seconds` | — | Seconds since the last OS keyboard/mouse input, or `-1` where unsupported. Drains the inbox as a side effect. |
82
+ | `get_idle_seconds` | — | How long since the last OS keyboard/mouse input, as one of two DIFFERENT answers: `idle: measured N seconds …`, or `idle: could not measure — user activity unknown, so activity gating is skipped` when this machine cannot report user activity at all. Drains the inbox as a side effect. |
81
83
  | `get_idle_config` | — | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`. Drains the inbox. |
82
84
  | `get_dnd_status` | — | `{ active, reason }` where reason is `disabled` (master mute or this client disabled), `manual`, `schedule` or `off`. Drains the inbox. |
83
85
  | `update_instructions` | `instructions` (≤4000), `target` = `global`\|`project` | Rewrites a marker-delimited block in `~/.claude/CLAUDE.md` (global) or `./.claude/CLAUDE.md` (project). |
@@ -273,7 +275,7 @@ rename, disable or force-reconnect one. `help.html` is the copy-paste setup page
273
275
  | `POST /api/slack/events` | Slack Events API receiver; HMAC-verified when `SLACK_SIGNING_SECRET` is set. |
274
276
  | `POST /api/ui/visibility` | The web UI heartbeats its own visibility; while visible, non-`high` notifications stay desktop-only. |
275
277
  | `GET /reply/:token`, `POST /reply/:token` | One-shot web reply page for `ask` over email. |
276
- | `GET /v1/health`, `/v1/info` | Liveness (used by the VS Code extension) and version stub. |
278
+ | `GET /v1/health`, `/v1/info` | Delivery report read by the VS Code extension's status bar: `{healthy, process, mcp, channels, reason}`. `healthy` is true only when the MCP endpoint is enabled **and** at least one channel is enabled and able to carry, so a listening server that could send nothing is reported unhealthy rather than green. `/v1/info` stays a version stub. |
277
279
  | `/:topic/sse`, `/:topic/json`, `PUT`/`POST /:topic`, `/:topic/subscribers` | Built-in ntfy protocol server (also mounted under `/ntfy/…`). |
278
280
 
279
281
  `POST /__test__/inject-inbox` and `GET /__test__/slack-clients` exist only when
@@ -298,7 +300,7 @@ rename, disable or force-reconnect one. `help.html` is the copy-paste setup page
298
300
  | Script | What it does |
299
301
  |---|---|
300
302
  | `notify-ui.sh` | Build if `dist/ui/server.js` is missing, then run the server in the foreground (**without** `ENABLE_MCP`). |
301
- | `restart.sh` | `npm run build:ui`, kill the running `dist/ui/server.js`, relaunch detached to `/tmp/notify-mcp.log`. |
303
+ | `restart.sh` | `npm run build:ui`, stop the one pid holding `:3737`, relaunch detached with `ENABLE_MCP=1` to `/tmp/notify-mcp.log`, then read `/v1/health` and exit non-zero unless the server reports it can deliver. |
302
304
  | `bus-up.sh` | Singleton watchdog: keeps `:3737` listening, redeploys when `dist/ui/server.js` changes on disk, and keeps one `notify-watch.sh` alive. Logs to `.run/`. |
303
305
  | `notify-watch.sh` | Detached responder. Long-polls `/api/agent/inbox/wait` for `<host>-<folder>-bot`, and passes every message **verbatim** to a headless `claude -p` that answers via `POST /api/agent/slack/reply`. |
304
306
  | `scripts/agent-notify.sh`, `agent-poll-inbox.sh`, `agent-wait-inbox.sh`, `agent-listen-loop.sh` | curl wrappers over the plain-HTTP agent API (`NOTIFY_BASE_URL`, `NOTIFY_AGENT_KEY`, `NOTIFY_TAG`, `NOTIFY_ON_MESSAGE_CMD`). |
package/dist/index.js CHANGED
@@ -7,7 +7,11 @@
7
7
  * listener) lives in the long-running HTTP server (`ui/server.js`, default
8
8
  * port 3737). The bridge:
9
9
  *
10
- * 1. Ensures the HTTP server is running (auto-spawns it detached if not).
10
+ * 1. Ensures the HTTP server is running, and auto-spawns it detached when a
11
+ * server ships beside this bridge — the npm package and this repo both put
12
+ * `ui/server.js` there. A bridge-only copy (the VS Code extension mirrors
13
+ * `dist/index.mjs` alone, deliberately: see vscode-extension/esbuild.js)
14
+ * has no server to start, so it reports that instead of spawning one.
11
15
  * 2. Subscribes to /api/inbox/stream via SSE and re-emits each unsolicited
12
16
  * user message as a `notifications/claude/channel` notification to the
13
17
  * attached client. Claude Code (v2.1.80+) surfaces those as synthetic
@@ -26,7 +30,7 @@
26
30
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
31
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
28
32
  import { spawn } from "child_process";
29
- import { existsSync } from "fs";
33
+ import { existsSync, readFileSync } from "fs";
30
34
  import { join, dirname, basename } from "path";
31
35
  import { hostname } from "os";
32
36
  import { fileURLToPath } from "url";
@@ -35,6 +39,16 @@ import { splitForNotify } from "./chunk.js";
35
39
  const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
36
40
  const BASE = `http://localhost:${PORT}`;
37
41
  const CLEAN_ID = (s) => s.toLowerCase().replace(/[^a-z0-9_-]/g, "");
42
+ const BRIDGE_VERSION = (() => {
43
+ const manifest = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
44
+ // An absent manifest is a legitimate state for a bridge-only copy and renders
45
+ // `unversioned`; a manifest present but unreadable throws.
46
+ if (!existsSync(manifest)) {
47
+ stderr(`[bridge] no manifest at ${manifest}, so this copy cannot name the build it is — a bridge-only copy has nothing to read. Install the server package, or start the server another way; the bridge still works, it just cannot say which version it is.`);
48
+ return "unversioned";
49
+ }
50
+ return JSON.parse(readFileSync(manifest, "utf8")).version;
51
+ })();
38
52
  // NOTIFY_MCP_TAG is the explicit per-window name; otherwise derive from the
39
53
  // workspace Claude Code passes (CLAUDE_PROJECT_DIR) — falling back to the launch
40
54
  // dir — and walk up to the nearest meaningful folder, skipping generic launcher/
@@ -120,16 +134,18 @@ function spawnUiServerIfNeeded() {
120
134
  ];
121
135
  const uiPath = candidates.find(p => existsSync(p));
122
136
  if (!uiPath) {
123
- stderr(`[bridge] could not locate ui/server.js near ${here} — skipping auto-spawn`);
124
- return;
137
+ stderr(`[bridge] no ui/server.js beside this bridge at ${here}, so there is nothing here to spawn — a bridge-only copy (the VS Code extension mirrors the bridge alone). Start a server with 'npx -y omni-notify-mcp' or the extension's Start Server command, or install the package so the server ships beside the bridge.`);
138
+ return false;
125
139
  }
126
140
  stderr(`[bridge] auto-spawning UI server: ${uiPath}`);
127
141
  const child = spawn(process.execPath, [uiPath], {
128
142
  detached: true,
129
143
  stdio: "ignore",
144
+ windowsHide: true,
130
145
  env: { ...process.env, PORT: String(PORT), ENABLE_MCP: "1" },
131
146
  });
132
147
  child.unref();
148
+ return true;
133
149
  }
134
150
  async function waitForServer(maxMs = 15_000) {
135
151
  const deadline = Date.now() + maxMs;
@@ -147,6 +163,16 @@ function stderr(line) {
147
163
  }
148
164
  catch { /* ignore */ }
149
165
  }
166
+ // A transport failure must NAME THE HOP — `fetch` reports a bare "fetch failed" with no host, port or cause, which is the exact string that hid a dead channel for ten minutes (#752). Do not simplify this back to `throw err`.
167
+ function unreachable(url, err) {
168
+ const cause = err?.cause;
169
+ const code = cause && typeof cause === "object" && "code" in cause
170
+ ? String(cause.code)
171
+ : undefined;
172
+ const why = code === "ECONNREFUSED" ? `nothing is listening on ${new URL(url).host}`
173
+ : code ?? (err instanceof Error ? err.message : String(err));
174
+ return new Error(`${url} is unreachable: ${why}`, { cause: err });
175
+ }
150
176
  // ── 2. HTTP /mcp session — the bridge itself is an MCP client of the server ──
151
177
  // We run a single persistent MCP-over-HTTP session per bridge process and
152
178
  // forward every local stdio tool call through it. Using one shared session
@@ -154,68 +180,122 @@ function stderr(line) {
154
180
  // draining all consistent with what the HTTP server sees.
155
181
  let httpSessionId;
156
182
  let httpRpcId = 1;
157
- async function httpRpc(method, params, isNotification = false) {
158
- // JSON-RPC notifications (method name starts with `notifications/` or the
159
- // caller says so) carry no `id` and get no response. Spec-compliant servers
160
- // return 202 Accepted with empty body.
161
- const notif = isNotification || method.startsWith("notifications/");
162
- const body = { jsonrpc: "2.0", method, params: params ?? {} };
163
- if (!notif)
164
- body.id = httpRpcId++;
183
+ let handshake;
184
+ async function postToMcp(body, sid) {
165
185
  const headers = {
166
186
  "Content-Type": "application/json",
167
187
  "Accept": "application/json, text/event-stream",
168
188
  };
169
- if (httpSessionId)
170
- headers["mcp-session-id"] = httpSessionId;
171
- const r = await fetch(`${BASE}/mcp${MCP_QUERY}`, {
172
- method: "POST",
173
- headers,
174
- body: JSON.stringify(body),
175
- // Long-poll tools may block up to ~55s server-side. Give the fetch
176
- // generous headroom but not infinite, so a wedged server surfaces fast.
177
- signal: AbortSignal.timeout(120_000),
178
- });
179
- // The server returns 404 when our cached session is stale (spec-compliant
180
- // behavior after a server restart). Clear and retry once with a fresh init.
181
- if (r.status === 404 && httpSessionId) {
182
- httpSessionId = undefined;
183
- await httpInitialize();
184
- return httpRpc(method, params, isNotification);
189
+ if (sid)
190
+ headers["mcp-session-id"] = sid;
191
+ const url = `${BASE}/mcp${MCP_QUERY}`;
192
+ let r;
193
+ try {
194
+ r = await fetch(url, {
195
+ method: "POST",
196
+ headers,
197
+ body: JSON.stringify(body),
198
+ // Long-poll tools may block up to ~55s server-side. Give the fetch
199
+ // generous headroom but not infinite, so a wedged server surfaces fast.
200
+ signal: AbortSignal.timeout(120_000),
201
+ });
202
+ }
203
+ catch (err) {
204
+ throw unreachable(url, err);
185
205
  }
186
206
  if (r.status >= 500) {
187
207
  throw new Error(`HTTP ${r.status} from /mcp: ${await r.text().catch(() => "")}`);
188
208
  }
189
- const sid = r.headers.get("mcp-session-id");
190
- if (sid && !httpSessionId)
191
- httpSessionId = sid;
192
- if (notif)
193
- return undefined;
194
- const ctype = r.headers.get("content-type") ?? "";
195
209
  const raw = await r.text();
210
+ const answer = { status: r.status, sessionId: r.headers.get("mcp-session-id") ?? undefined, raw };
211
+ if (!raw)
212
+ return answer;
213
+ const ctype = r.headers.get("content-type") ?? "";
196
214
  if (ctype.includes("application/json")) {
197
- return JSON.parse(raw);
215
+ answer.json = JSON.parse(raw);
216
+ return answer;
198
217
  }
199
218
  // SSE framing: pull the first data: line as the JSON-RPC response.
200
219
  for (const line of raw.split(/\r?\n/)) {
201
220
  if (line.startsWith("data:")) {
202
221
  const json = line.slice(5).trim();
203
- if (json)
204
- return JSON.parse(json);
222
+ if (json) {
223
+ answer.json = JSON.parse(json);
224
+ return answer;
225
+ }
205
226
  }
206
227
  }
207
- throw new Error(`unexpected response from /mcp: ${raw.slice(0, 200)}`);
228
+ return answer;
208
229
  }
209
- async function httpInitialize() {
210
- const res = await httpRpc("initialize", {
211
- protocolVersion: "2024-11-05",
212
- capabilities: {},
213
- clientInfo: { name: CLIENT_NAME, version: "1.0" },
214
- });
215
- if (res?.error)
216
- throw new Error(`initialize failed: ${JSON.stringify(res.error)}`);
217
- // Follow-up: the spec requires a notifications/initialized after initialize.
218
- await httpRpc("notifications/initialized").catch(() => { });
230
+ function sessionWasRefused(answer) {
231
+ return answer.status === 404 || (answer.status === 400 && answer.json?.error?.code === -32000);
232
+ }
233
+ function sessionRefusal(answer) {
234
+ if (answer.json?.error === "mcp_disabled") {
235
+ return new Error(`${BASE}/mcp cannot carry a message: the server has its MCP endpoint DISABLED (start it with ENABLE_MCP=1)`);
236
+ }
237
+ const said = answer.json?.error ? JSON.stringify(answer.json.error) : answer.raw.slice(0, 200);
238
+ return new Error(`${BASE}/mcp refused the session (HTTP ${answer.status}): ${said}`);
239
+ }
240
+ async function liveSession() {
241
+ const current = httpSessionId;
242
+ if (current)
243
+ return current;
244
+ handshake ??= openSession();
245
+ return handshake;
246
+ }
247
+ async function openSession() {
248
+ try {
249
+ const res = await postToMcp({
250
+ jsonrpc: "2.0",
251
+ id: httpRpcId++,
252
+ method: "initialize",
253
+ params: {
254
+ protocolVersion: "2024-11-05",
255
+ capabilities: {},
256
+ clientInfo: { name: CLIENT_NAME, version: "1.0" },
257
+ },
258
+ }, undefined);
259
+ if (res.json?.error)
260
+ throw sessionRefusal(res);
261
+ const sid = res.sessionId;
262
+ if (!sid)
263
+ throw new Error(`/mcp answered initialize with no session id (HTTP ${res.status}): ${res.raw.slice(0, 200)}`);
264
+ httpSessionId = sid;
265
+ // Follow-up: the spec requires a notifications/initialized after initialize.
266
+ await postToMcp({ jsonrpc: "2.0", method: "notifications/initialized" }, sid);
267
+ return sid;
268
+ }
269
+ finally {
270
+ handshake = undefined;
271
+ }
272
+ }
273
+ async function httpRpc(method, params, isNotification = false) {
274
+ // JSON-RPC notifications (method name starts with `notifications/` or the
275
+ // caller says so) carry no `id` and get no response. Spec-compliant servers
276
+ // return 202 Accepted with empty body.
277
+ const notif = isNotification || method.startsWith("notifications/");
278
+ return sendOverSession(method, params, notif, false);
279
+ }
280
+ async function sendOverSession(method, params, notif, rehandshaken) {
281
+ const sid = await liveSession();
282
+ const body = { jsonrpc: "2.0", method, params: params ?? {} };
283
+ if (!notif)
284
+ body.id = httpRpcId++;
285
+ const answer = await postToMcp(body, sid);
286
+ if (sessionWasRefused(answer)) {
287
+ if (httpSessionId === sid)
288
+ httpSessionId = undefined;
289
+ if (rehandshaken) {
290
+ throw sessionRefusal(answer);
291
+ }
292
+ return sendOverSession(method, params, notif, true);
293
+ }
294
+ if (notif)
295
+ return undefined;
296
+ if (!answer.json)
297
+ throw new Error(`unexpected response from /mcp: ${answer.raw.slice(0, 200)}`);
298
+ return answer.json;
219
299
  }
220
300
  // Periodically touch our /mcp session so the server's reaper doesn't prune it.
221
301
  // Without this, the bridge opens a session on startup, proxies tool calls
@@ -233,7 +313,7 @@ function startSessionKeepalive() {
233
313
  }, 30_000);
234
314
  }
235
315
  // ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
236
- const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
316
+ const server = new McpServer({ name: "notify-mcp", version: BRIDGE_VERSION }, {
237
317
  // Declare the claude/channel capability so Claude Code knows to surface
238
318
  // our notifications/claude/channel events as synthetic user turns. The
239
319
  // `reply` tool below is how Claude hands the agent's response back to us.
@@ -291,17 +371,52 @@ async function proxyToolCall(name, args) {
291
371
  }
292
372
  return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
293
373
  }
374
+ async function deliverOverAgentApi(message, priority, mcpFailure) {
375
+ const headers = { "Content-Type": "application/json" };
376
+ const key = (process.env.NOTIFY_AGENT_KEY ?? "").trim();
377
+ if (key)
378
+ headers["x-notify-key"] = key;
379
+ const tagPrefix = SESSION_TAG ? `[${SESSION_TAG}] ` : "";
380
+ const agentUrl = `${BASE}/api/agent/notify`;
381
+ const failed = (detail) => ({
382
+ content: [{ type: "text", text: `NOT DELIVERED — outcome=failed; neither door carried it: ${mcpFailure}; ${detail}` }],
383
+ isError: true,
384
+ });
385
+ try {
386
+ const r = await fetch(agentUrl, {
387
+ method: "POST",
388
+ headers,
389
+ body: JSON.stringify({ message: `${tagPrefix}${message}`, priority }),
390
+ signal: AbortSignal.timeout(120_000),
391
+ });
392
+ const out = await r.json().catch(() => undefined);
393
+ if (out?.outcome !== "delivered") {
394
+ return failed(`${agentUrl} answered HTTP ${r.status} ${JSON.stringify(out ?? {})}`);
395
+ }
396
+ stderr(`[bridge] /mcp could not carry a notify (${mcpFailure}); delivered over /api/agent/notify instead`);
397
+ return {
398
+ content: [{
399
+ type: "text",
400
+ text: `outcome=delivered attempted=[${(out.attempted ?? []).join(", ")}] delivered=[${(out.delivered ?? []).join(", ")}]`,
401
+ }],
402
+ };
403
+ }
404
+ catch (err) {
405
+ return failed(unreachable(agentUrl, err).message);
406
+ }
407
+ }
294
408
  async function sendNotifyChunked(message, priority) {
295
409
  const chunks = splitForNotify(message);
296
410
  if (chunks.length === 1)
297
411
  return proxyToolCall("notify", { message, priority });
412
+ // Every chunk is sent, and the ANSWER is composed from all of them — a chunk
413
+ // that reached nobody must not short-circuit the report into one chunk's text,
414
+ // losing how many of how many failed (#774: the server now flags such a result
415
+ // as an error itself, so a short-circuit here would swallow the summary).
298
416
  const results = [];
299
417
  for (const chunk of chunks) {
300
418
  results.push(await proxyToolCall("notify", { message: chunk, priority }));
301
419
  }
302
- const failed = results.find(r => r.isError);
303
- if (failed)
304
- return failed;
305
420
  const texts = results.map(r => r.content?.[0]?.text ?? "");
306
421
  const inboxMarker = "⚠️ USER SENT YOU A MESSAGE";
307
422
  const inboxBlocks = texts
@@ -343,7 +458,15 @@ server.tool("notify", "Send a notification to the user. Delivery channels and DN
343
458
  "delivered in order. Never truncate to fit a part.", {
344
459
  message: z.string().max(5000),
345
460
  priority: z.enum(["low", "normal", "high"]).default("normal"),
346
- }, async ({ message, priority }) => sendNotifyChunked(message, priority));
461
+ }, async ({ message, priority }) => {
462
+ try {
463
+ return await sendNotifyChunked(message, priority);
464
+ }
465
+ catch (err) {
466
+ // OWNER: a notification the user never sees is the whole defect this tool exists to prevent, so a /mcp transport failure must not end the send — the same server's /api/agent/notify door carries no session and finishes it, and the server's own log stream records the outcome either way.
467
+ return deliverOverAgentApi(message, priority, err instanceof Error ? err.message : String(err));
468
+ }
469
+ });
347
470
  server.tool("ask", "Send a question to the user and wait for their reply.", {
348
471
  question: z.string().max(500),
349
472
  timeout_seconds: z.number().min(30).max(3600).default(300),
@@ -379,7 +502,13 @@ server.tool("reply", "Reply to the user's most recent channel message. Routes th
379
502
  priority: z.enum(["low", "normal", "high"]).default("normal"),
380
503
  }, async ({ message, priority }) => {
381
504
  const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
382
- return sendNotifyChunked(`${tagPrefix}${message}`, priority);
505
+ try {
506
+ return await sendNotifyChunked(`${tagPrefix}${message}`, priority);
507
+ }
508
+ catch (err) {
509
+ // OWNER: the user is waiting on this reply over the channel they are reading, so a /mcp transport failure must not end it — the same server's /api/agent/notify door carries no session and finishes it, and the server's own log stream records the outcome either way.
510
+ return deliverOverAgentApi(message, priority, err instanceof Error ? err.message : String(err));
511
+ }
383
512
  });
384
513
  async function subscribeInbox() {
385
514
  const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
@@ -413,12 +542,20 @@ async function subscribeInbox() {
413
542
  const payload = line.slice(5).trim();
414
543
  if (!payload)
415
544
  continue;
545
+ let entry;
416
546
  try {
417
- const entry = JSON.parse(payload);
418
- await emitChannelEvent(entry);
547
+ entry = JSON.parse(payload);
419
548
  }
420
549
  catch (err) {
421
550
  stderr(`[bridge] bad SSE payload: ${err instanceof Error ? err.message : String(err)}`);
551
+ continue;
552
+ }
553
+ // OWNER: ITER/LOOP — one frame that cannot be emitted must not end the stream, so this frame reports itself with its own cause and the next one is read.
554
+ try {
555
+ await emitChannelEvent(entry);
556
+ }
557
+ catch (err) {
558
+ stderr(`[bridge] channel notification NOT DELIVERED: ${err instanceof Error ? err.message : String(err)}`);
422
559
  }
423
560
  }
424
561
  }
@@ -432,25 +569,15 @@ async function subscribeInbox() {
432
569
  }
433
570
  }
434
571
  async function emitChannelEvent(entry) {
435
- // Emit the new Claude Code Channels notification *and* the generic
436
- // `notifications/message` as a belt-and-suspenders approach: hosts that
437
- // ignore `notifications/claude/channel` may still surface the message as
438
- // a log line. Both are fire-and-forget; a failure just means the peer
439
- // closed the stdio transport (we'll notice on the next tool call).
572
+ // Emit the new Claude Code Channels notification; `wait_for_inbox` remains the universal fallback for a host that does not carry it.
440
573
  const content = entry.tag ? `[@${entry.tag}] ${entry.text}` : entry.text;
441
- try {
442
- await server.server.notification({
443
- method: "notifications/claude/channel",
444
- params: {
445
- content,
446
- meta: { ts: entry.ts, tag: entry.tag ?? null, source: "notify-mcp" },
447
- },
448
- });
449
- }
450
- catch {
451
- // client doesn't support the experimental capability — that's fine,
452
- // wait_for_inbox is the universal fallback.
453
- }
574
+ await server.server.notification({
575
+ method: "notifications/claude/channel",
576
+ params: {
577
+ content,
578
+ meta: { ts: entry.ts, tag: entry.tag ?? null, source: "notify-mcp" },
579
+ },
580
+ });
454
581
  }
455
582
  // ── 5. Wire it up ────────────────────────────────────────────────────────────
456
583
  // When the parent (claude.exe / Cursor / Codex) dies, the OS closes our stdin
@@ -480,17 +607,14 @@ async function shutdownOnPeerLoss(why) {
480
607
  }
481
608
  async function main() {
482
609
  if (!(await serverIsUp())) {
483
- spawnUiServerIfNeeded();
610
+ const spawned = spawnUiServerIfNeeded();
484
611
  if (!(await waitForServer())) {
485
- stderr(`[bridge] HTTP server at ${BASE} did not come up within 15s — giving up on push; tool calls will fail.`);
612
+ stderr(spawned
613
+ ? `[bridge] HTTP server at ${BASE} did not come up within 15s of being spawned — giving up on push; tool calls will fail.`
614
+ : `[bridge] no HTTP server at ${BASE} and none was spawned (see above) — giving up on push; tool calls will fail.`);
486
615
  }
487
616
  }
488
- try {
489
- await httpInitialize();
490
- }
491
- catch (err) {
492
- stderr(`[bridge] initial HTTP initialize failed: ${err instanceof Error ? err.message : String(err)}`);
493
- }
617
+ await liveSession().catch(err => stderr(`[bridge] initial /mcp handshake failed: ${err instanceof Error ? err.message : String(err)} — the next tool call handshakes again`));
494
618
  // Fire-and-forget: the stdio transport should be usable immediately; the
495
619
  // push channel attaches as soon as the SSE handshake completes.
496
620
  subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));