omni-notify-mcp 1.3.12 → 1.3.14

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
@@ -13,7 +13,7 @@
13
13
 
14
14
  <p align="center">
15
15
  <a href="https://www.npmjs.com/package/omni-notify-mcp"><img src="https://img.shields.io/npm/v/omni-notify-mcp.svg" alt="npm"></a>
16
- <a href="https://marketplace.visualstudio.com/items?itemName=MeniHillel.omni-notify-mcp"><img src="https://img.shields.io/visual-studio-marketplace/v/MeniHillel.omni-notify-mcp?label=marketplace" alt="VS Code Marketplace"></a>
16
+ <a href="https://marketplace.visualstudio.com/items?itemName=Karish911.omni-notify-mcp"><img src="https://img.shields.io/visual-studio-marketplace/v/Karish911.omni-notify-mcp?label=marketplace" alt="VS Code Marketplace"></a>
17
17
  <img src="https://img.shields.io/badge/license-MIT-4ea3ff.svg" alt="MIT license">
18
18
  <img src="https://img.shields.io/badge/MCP-compatible-4ea3ff.svg" alt="MCP compatible">
19
19
  </p>
@@ -110,6 +110,11 @@ Subscribe to `GET /api/inbox/stream` (text/event-stream) to receive unsolicited
110
110
  ### Multi-session tagging
111
111
  Run multiple agents against the same notify server (e.g. one Claude session in `repo-a`, another in `repo-b`). Each connects with `?tag=<name>` and the user can route a Telegram message to a specific agent by prefixing `@<name>`. Untagged messages broadcast to every session.
112
112
 
113
+ The stdio bridge derives its tag as `<hostname>-<workspace-folder>` unless you override it with the **`NOTIFY_MCP_TAG`** env var — set it to give a bridge a durable, human-meaningful name (e.g. `NOTIFY_MCP_TAG=frontend`). This is the only stable per-bridge name: Claude Code exposes no per-session id to MCP subprocesses, so two extension panels on the *same* workspace share a tag by default.
114
+
115
+ ### Multiple panels in one window
116
+ Open N Claude extension panels in one VS Code window and each spawns its own bridge → its own MCP session. They share a tag (same host + workspace), but `/api/clients` lists each as a separate logical client (one per panel), disambiguated by an auto-suffixed id (`foo`, `foo-2`, …) plus `panel <n>/<total>` and a short session id. `list clients` annotates the panel count. To give a panel its own durable identity instead of an ordinal, launch it with a distinct `NOTIFY_MCP_TAG`.
117
+
113
118
  ### Do Not Disturb
114
119
  - **Manual toggle** — flip it on, all `priority < high` notifs drop on the floor.
115
120
  - **Scheduled quiet hours** — e.g. 22:00 → 08:00, configurable per-day.
@@ -1,26 +1,33 @@
1
- {
2
- "desktop": {
3
- "enabled": true
4
- },
5
- "whatsapp": {
6
- "enabled": true,
7
- "phone": "+1234567890",
8
- "apikey": "YOUR_CALLMEBOT_APIKEY"
9
- },
10
- "sms": {
11
- "enabled": true,
12
- "accountSid": "YOUR_TWILIO_ACCOUNT_SID",
13
- "authToken": "YOUR_TWILIO_AUTH_TOKEN",
14
- "from": "+1YOUR_TWILIO_NUMBER",
15
- "to": "+1YOUR_PERSONAL_NUMBER"
16
- },
17
- "email": {
18
- "enabled": true,
19
- "host": "smtp.gmail.com",
20
- "port": 587,
21
- "secure": false,
22
- "user": "your-email@gmail.com",
23
- "pass": "YOUR_GMAIL_APP_PASSWORD",
24
- "to": "your-email@gmail.com"
25
- }
26
- }
1
+ {
2
+ "desktop": {
3
+ "enabled": true
4
+ },
5
+ "telegram": {
6
+ "enabled": true,
7
+ "token": "YOUR_BOT_TOKEN",
8
+ "chatIds": ["123456789", "-1009876543210"]
9
+ },
10
+ "sms": {
11
+ "enabled": true,
12
+ "accessKeyId": "YOUR_AWS_ACCESS_KEY_ID",
13
+ "secretAccessKey": "YOUR_AWS_SECRET_ACCESS_KEY",
14
+ "region": "us-east-1",
15
+ "originationNumber": "+1YOUR_AWS_ORIGINATION_NUMBER",
16
+ "to": ["+1YOUR_PERSONAL_NUMBER", "+1ANOTHER_NUMBER"]
17
+ },
18
+ "slack": {
19
+ "enabled": true,
20
+ "botToken": "xoxb-YOUR-BOT-TOKEN",
21
+ "channels": ["C0123456789", "C0987654321"],
22
+ "webhookUrl": ""
23
+ },
24
+ "email": {
25
+ "enabled": true,
26
+ "host": "smtp.gmail.com",
27
+ "port": 587,
28
+ "secure": false,
29
+ "user": "your-email@gmail.com",
30
+ "pass": "YOUR_GMAIL_APP_PASSWORD",
31
+ "to": "your-email@gmail.com"
32
+ }
33
+ }
@@ -3,22 +3,33 @@ export async function sendSlack(config, message, priority = "normal", title = "C
3
3
  if (!config.enabled)
4
4
  return;
5
5
  const emoji = EMOJI_MAP[priority] ?? EMOJI_MAP.normal;
6
+ const blocks = [
7
+ { type: "section", text: { type: "mrkdwn", text: `${emoji} *${title}*\n${message}` } },
8
+ { type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${priority} · <!date^${Math.floor(Date.now() / 1000)}^{time}|now>` }] },
9
+ ];
10
+ if (config.botToken && config.channels?.length) {
11
+ const errors = [];
12
+ let sent = 0;
13
+ for (const channel of config.channels) {
14
+ const res = await fetch("https://slack.com/api/chat.postMessage", {
15
+ method: "POST",
16
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.botToken}` },
17
+ body: JSON.stringify({ channel, text: `${emoji} *${title}*`, blocks }),
18
+ });
19
+ const json = await res.json().catch(() => ({}));
20
+ if (res.ok && json.ok)
21
+ sent++;
22
+ else
23
+ errors.push(`${channel}: ${json.error ?? res.status}`);
24
+ }
25
+ if (sent === 0 && errors.length)
26
+ throw new Error(`Slack error — ${errors.join("; ")}`);
27
+ return;
28
+ }
6
29
  const res = await fetch(config.webhookUrl, {
7
30
  method: "POST",
8
31
  headers: { "Content-Type": "application/json" },
9
- body: JSON.stringify({
10
- text: `${emoji} *${title}*`,
11
- blocks: [
12
- {
13
- type: "section",
14
- text: { type: "mrkdwn", text: `${emoji} *${title}*\n${message}` },
15
- },
16
- {
17
- type: "context",
18
- elements: [{ type: "mrkdwn", text: `Priority: ${priority} · <!date^${Math.floor(Date.now() / 1000)}^{time}|now>` }],
19
- },
20
- ],
21
- }),
32
+ body: JSON.stringify({ text: `${emoji} *${title}*`, blocks }),
22
33
  });
23
34
  if (!res.ok)
24
35
  throw new Error(`Slack ${res.status}: ${await res.text()}`);
@@ -1,7 +1,27 @@
1
- import twilio from "twilio";
1
+ import { PinpointSMSVoiceV2Client, SendTextMessageCommand } from "@aws-sdk/client-pinpoint-sms-voice-v2";
2
2
  export async function sendSms(config, message) {
3
3
  if (!config.enabled)
4
4
  return;
5
- const client = twilio(config.accountSid, config.authToken);
6
- await client.messages.create({ body: message, from: config.from, to: config.to });
5
+ const client = new PinpointSMSVoiceV2Client({
6
+ region: config.region,
7
+ credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
8
+ });
9
+ const errors = [];
10
+ let sent = 0;
11
+ const e164 = (s) => String(s ?? "").replace(/[^\d+]/g, "");
12
+ for (const to of config.to ?? []) {
13
+ try {
14
+ await client.send(new SendTextMessageCommand({
15
+ DestinationPhoneNumber: e164(to),
16
+ OriginationIdentity: e164(config.originationNumber) || undefined,
17
+ MessageBody: message,
18
+ }));
19
+ sent++;
20
+ }
21
+ catch (err) {
22
+ errors.push(`${to}: ${err instanceof Error ? err.message : String(err)}`);
23
+ }
24
+ }
25
+ if (sent === 0 && errors.length)
26
+ throw new Error(`SMS error — ${errors.join("; ")}`);
7
27
  }
@@ -1,11 +1,19 @@
1
1
  export async function sendTelegram(config, message) {
2
2
  if (!config.enabled)
3
3
  return;
4
- const res = await fetch(`https://api.telegram.org/bot${config.token}/sendMessage`, {
5
- method: "POST",
6
- headers: { "Content-Type": "application/json" },
7
- body: JSON.stringify({ chat_id: config.chatId, text: message }),
8
- });
9
- if (!res.ok)
10
- throw new Error(`Telegram error ${res.status}: ${await res.text()}`);
4
+ const errors = [];
5
+ let sent = 0;
6
+ for (const chatId of config.chatIds ?? []) {
7
+ const res = await fetch(`https://api.telegram.org/bot${config.token}/sendMessage`, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify({ chat_id: chatId, text: message }),
11
+ });
12
+ if (res.ok)
13
+ sent++;
14
+ else
15
+ errors.push(`${chatId}: ${res.status} ${await res.text()}`);
16
+ }
17
+ if (sent === 0 && errors.length)
18
+ throw new Error(`Telegram error — ${errors.join("; ")}`);
11
19
  }
package/dist/chunk.js ADDED
@@ -0,0 +1,37 @@
1
+ export const NOTIFY_CHUNK_LIMIT = 500;
2
+ function packChunks(message, body, wordAware) {
3
+ const chunks = [];
4
+ let rest = message;
5
+ while (rest.length > body) {
6
+ let cut = body;
7
+ if (wordAware) {
8
+ const window = rest.slice(0, body + 1);
9
+ const brk = Math.max(window.lastIndexOf(" "), window.lastIndexOf("\n"));
10
+ if (brk >= Math.floor(body * 0.6))
11
+ cut = brk;
12
+ }
13
+ chunks.push(rest.slice(0, cut).replace(/\s+$/, ""));
14
+ rest = rest.slice(cut).replace(/^\s+/, "");
15
+ }
16
+ if (rest.length)
17
+ chunks.push(rest);
18
+ return chunks;
19
+ }
20
+ export function splitForNotify(message, limit = NOTIFY_CHUNK_LIMIT) {
21
+ if (message.length <= limit)
22
+ return [message];
23
+ let parts = Math.ceil(message.length / limit);
24
+ for (;;) {
25
+ const reserve = `(${parts}/${parts}) `.length;
26
+ const needed = Math.ceil(message.length / Math.max(1, limit - reserve));
27
+ if (needed <= parts)
28
+ break;
29
+ parts = needed;
30
+ }
31
+ const body = Math.max(1, limit - `(${parts}/${parts}) `.length);
32
+ let chunks = packChunks(message, body, true);
33
+ if (chunks.length > parts)
34
+ chunks = packChunks(message, body, false);
35
+ const total = chunks.length;
36
+ return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
37
+ }
package/dist/index.js CHANGED
@@ -31,13 +31,16 @@ import { join, dirname, basename } from "path";
31
31
  import { hostname } from "os";
32
32
  import { fileURLToPath } from "url";
33
33
  import { z } from "zod";
34
+ import { splitForNotify } from "./chunk.js";
34
35
  const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
35
36
  const BASE = `http://localhost:${PORT}`;
36
37
  const CLEAN_ID = (s) => s.toLowerCase().replace(/[^a-z0-9_-]/g, "");
37
- // NOTIFY_MCP_TAG is the explicit per-window name; otherwise use the nearest
38
- // meaningful working-dir folder, skipping generic launcher/tool/system dirs so a
39
- // bridge spawned from an editor's launch dir names itself after the project
40
- // (e.g. "bullseyenotify"), never the host ("claude-code").
38
+ // NOTIFY_MCP_TAG is the explicit per-window name; otherwise derive from the
39
+ // workspace Claude Code passes (CLAUDE_PROJECT_DIR) falling back to the launch
40
+ // dir and walk up to the nearest meaningful folder, skipping generic launcher/
41
+ // tool/system dirs so each window names itself after its project (e.g.
42
+ // "bullseyenotify" / "alphawave"), never the host ("claude-code"). Using
43
+ // CLAUDE_PROJECT_DIR is what lets one globally-wired bridge self-tag per window.
41
44
  function deriveVscId() {
42
45
  const explicit = CLEAN_ID(process.env.NOTIFY_MCP_TAG ?? "");
43
46
  if (explicit)
@@ -47,7 +50,8 @@ function deriveVscId() {
47
50
  "bin", "dist", "build", "src", "out", "node_modules",
48
51
  "windows", "system32", "users", "appdata", "roaming", "local", "programs", "temp", "tmp",
49
52
  ]);
50
- let dir = process.cwd();
53
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
54
+ let dir = root;
51
55
  for (let i = 0; i < 5; i++) {
52
56
  const name = CLEAN_ID(basename(dir));
53
57
  if (name && !generic.has(name))
@@ -57,11 +61,38 @@ function deriveVscId() {
57
61
  break;
58
62
  dir = parent;
59
63
  }
60
- return CLEAN_ID(basename(process.cwd())) || "agent";
64
+ return CLEAN_ID(basename(root)) || "agent";
61
65
  }
62
66
  const VSC_ID = deriveVscId();
63
- const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}`;
67
+ // In the VS Code extension Claude Code does NOT set CLAUDE_PROJECT_DIR for MCP
68
+ // servers, and the bridge's cwd is shared across windows (VSCODE_CWD), so the
69
+ // project name alone collapses every window to one tag. When no explicit tag /
70
+ // project dir was provided, append a short stable hash of CLAUDE_CODE_SESSION_ID
71
+ // (shared by a session + its subagents → they still fold) so each window is a
72
+ // distinct client. The extension supplies the readable workspace name separately.
73
+ const SESSION_SUFFIX = (() => {
74
+ if (process.env.NOTIFY_MCP_TAG || process.env.CLAUDE_PROJECT_DIR)
75
+ return "";
76
+ const sid = (process.env.CLAUDE_CODE_SESSION_ID || "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
77
+ return sid ? `-${sid.slice(0, 6)}` : "";
78
+ })();
79
+ const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}${SESSION_SUFFIX}`;
64
80
  const CLIENT_NAME = "claude-channel-bridge";
81
+ // CLAUDE_CODE_SESSION_ID is shared by an interactive Claude session AND every
82
+ // subagent (Task tool) it spawns — a spawned subagent inherits the parent's id
83
+ // verbatim. Reporting it lets the server fold a main session + its subagents
84
+ // into one interactive panel, so subagents don't inflate the clients tab. Empty
85
+ // for non-Claude-Code hosts (Cursor/Codex) — those stay one-panel-per-bridge.
86
+ const HOST_SESSION_ID = (process.env.CLAUDE_CODE_SESSION_ID || "").replace(/[^a-zA-Z0-9_-]/g, "");
87
+ const MCP_QUERY = (() => {
88
+ const p = new URLSearchParams();
89
+ if (SESSION_TAG)
90
+ p.set("tag", SESSION_TAG);
91
+ if (HOST_SESSION_ID)
92
+ p.set("hsid", HOST_SESSION_ID);
93
+ const s = p.toString();
94
+ return s ? `?${s}` : "";
95
+ })();
65
96
  // ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
66
97
  async function serverIsUp() {
67
98
  try {
@@ -137,8 +168,7 @@ async function httpRpc(method, params, isNotification = false) {
137
168
  };
138
169
  if (httpSessionId)
139
170
  headers["mcp-session-id"] = httpSessionId;
140
- const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
141
- const r = await fetch(`${BASE}/mcp${tagQuery}`, {
171
+ const r = await fetch(`${BASE}/mcp${MCP_QUERY}`, {
142
172
  method: "POST",
143
173
  headers,
144
174
  body: JSON.stringify(body),
@@ -229,15 +259,14 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
229
259
  "shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
230
260
  "SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
231
261
  "Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
232
- "🚨 500-CHAR LIMITCHUNK, NEVER TRUNCATE 🚨\n" +
233
- "The `notify` tool rejects bodies > 500 chars with `MCP error -32602: too_big`. " +
234
- "When you have more to say than fits in 500 chars, you MUST split into MULTIPLE " +
235
- "notify calls do NOT silently shorten the body. Procedure: (1) decide what " +
236
- "the user MUST see (every fact, file path, line number, recommendation); (2) if " +
237
- "the body exceeds 500 chars, split into N chunks numbered '(1/N) ...', '(2/N) ...', " +
238
- "each 500 chars including the prefix; (3) send all N chunks in order via separate " +
239
- "notify calls and echo all N IN FULL in chat. NEVER respond to a `too_big` error by " +
240
- "shortening to a single chunk — that loses information the user explicitly needs.\n\n" +
262
+ "🚨 NEVER TRUNCATETHE SERVER CHUNKS FOR YOU 🚨\n" +
263
+ "Send the COMPLETE message (up to 5000 chars) in a single `notify` call. Bodies over " +
264
+ "500 chars are automatically split server-side into numbered '(1/N) ...', '(2/N) ...' " +
265
+ "chunks (each ≤500 chars) and delivered in order you no longer hand-split, and a " +
266
+ "`too_big` error only fires past the 5000-char cap. Decide what the user MUST see " +
267
+ "(every fact, file path, line number, recommendation), send it all in one call, and " +
268
+ "echo it IN FULL in chat. NEVER shorten or summarize to fit that loses information " +
269
+ "the user explicitly needs.\n\n" +
241
270
  "When the user asks you to remember a behavioral rule or change how you should act, " +
242
271
  "call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
243
272
  "so the instructions persist across sessions and context compaction.",
@@ -256,13 +285,46 @@ async function proxyToolCall(name, args) {
256
285
  }
257
286
  return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
258
287
  }
288
+ async function sendNotifyChunked(message, priority) {
289
+ const chunks = splitForNotify(message);
290
+ if (chunks.length === 1)
291
+ return proxyToolCall("notify", { message, priority });
292
+ const results = [];
293
+ for (const chunk of chunks) {
294
+ results.push(await proxyToolCall("notify", { message: chunk, priority }));
295
+ }
296
+ const failed = results.find(r => r.isError);
297
+ if (failed)
298
+ return failed;
299
+ const texts = results.map(r => r.content?.[0]?.text ?? "");
300
+ const inboxMarker = "⚠️ USER SENT YOU A MESSAGE";
301
+ const inboxBlocks = texts
302
+ .map(t => { const i = t.indexOf(inboxMarker); return i === -1 ? null : t.slice(i); })
303
+ .filter((b) => b !== null);
304
+ const inboxSuffix = inboxBlocks.length ? `\n\n${inboxBlocks.join("\n\n")}` : "";
305
+ const delivered = texts.filter(t => t.includes("Sent via:")).length;
306
+ const n = chunks.length;
307
+ let summary;
308
+ if (delivered === n) {
309
+ summary = `Delivered as ${n} chunks (${message.length} chars).`;
310
+ }
311
+ else {
312
+ const stripInbox = (t) => { const i = t.indexOf(inboxMarker); return (i === -1 ? t : t.slice(0, i)).trim(); };
313
+ const failedSummaries = [...new Set(texts.filter(t => !t.includes("Sent via:")).map(stripInbox))];
314
+ const said = failedSummaries.map(s => `"${s}"`).join(", ");
315
+ summary = delivered === 0
316
+ ? `Suppressed — 0 of ${n} chunks reached any channel. Server said: ${said}`
317
+ : `Delivered ${delivered}/${n} chunks (${message.length} chars); ${n - delivered} reached no channel — server said: ${said}.`;
318
+ }
319
+ return { content: [{ type: "text", text: `${summary}${inboxSuffix}` }] };
320
+ }
259
321
  server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
260
- "MAX 500 CHARS PER MESSAGE. If you have more to say, split into multiple notify " +
261
- "calls with '(1/N) ...', '(2/N) ...' prefixesnever silently shorten on " +
262
- "`too_big` error; that loses information the user needs.", {
263
- message: z.string().max(500),
322
+ "Messages over 500 chars are automatically split into numbered '(1/N) ...' chunks " +
323
+ "(each ≤500 chars) and delivered in ordersend the full message in one call, never " +
324
+ "truncate. Hard cap 5000 chars.", {
325
+ message: z.string().max(5000),
264
326
  priority: z.enum(["low", "normal", "high"]).default("normal"),
265
- }, async (args) => proxyToolCall("notify", args));
327
+ }, async ({ message, priority }) => sendNotifyChunked(message, priority));
266
328
  server.tool("ask", "Send a question to the user and wait for their reply.", {
267
329
  question: z.string().max(500),
268
330
  timeout_seconds: z.number().min(30).max(3600).default(300),
@@ -292,7 +354,7 @@ server.tool("reply", "Reply to the user's most recent channel message. Routes th
292
354
  priority: z.enum(["low", "normal", "high"]).default("normal"),
293
355
  }, async ({ message, priority }) => {
294
356
  const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
295
- return proxyToolCall("notify", { message: `${tagPrefix}${message}`, priority });
357
+ return sendNotifyChunked(`${tagPrefix}${message}`, priority);
296
358
  });
297
359
  async function subscribeInbox() {
298
360
  const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
@@ -366,6 +428,31 @@ async function emitChannelEvent(entry) {
366
428
  }
367
429
  }
368
430
  // ── 5. Wire it up ────────────────────────────────────────────────────────────
431
+ // When the parent (claude.exe / Cursor / Codex) dies, the OS closes our stdin
432
+ // pipe and the stdio transport reaches EOF. Without exiting here the bridge
433
+ // lives on: startSessionKeepalive() keeps pinging /mcp every 30s, so the
434
+ // server's lastSeen never goes stale and the 90s reaper never removes the
435
+ // session — a closed window leaves a phantom "panel" in the clients tab
436
+ // forever. On EOF we DELETE our /mcp session (instant server-side removal) and
437
+ // exit so the panel disappears immediately.
438
+ let shuttingDown = false;
439
+ async function shutdownOnPeerLoss(why) {
440
+ if (shuttingDown)
441
+ return;
442
+ shuttingDown = true;
443
+ stderr(`[bridge] ${why} — closing /mcp session and exiting`);
444
+ if (httpSessionId) {
445
+ try {
446
+ await fetch(`${BASE}/mcp`, {
447
+ method: "DELETE",
448
+ headers: { "mcp-session-id": httpSessionId },
449
+ signal: AbortSignal.timeout(2000),
450
+ });
451
+ }
452
+ catch { /* server may already be gone; the reaper will clear it */ }
453
+ }
454
+ process.exit(0);
455
+ }
369
456
  async function main() {
370
457
  if (!(await serverIsUp())) {
371
458
  spawnUiServerIfNeeded();
@@ -385,6 +472,10 @@ async function main() {
385
472
  startSessionKeepalive();
386
473
  const transport = new StdioServerTransport();
387
474
  await server.connect(transport);
475
+ // EOF on stdin = the parent process is gone. Tear down so we don't linger as
476
+ // an orphan heartbeating a phantom panel.
477
+ process.stdin.on("end", () => { void shutdownOnPeerLoss("stdin ended (parent gone)"); });
478
+ process.stdin.on("close", () => { void shutdownOnPeerLoss("stdin closed (parent gone)"); });
388
479
  stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
389
480
  }
390
481
  main().catch(err => {
@@ -19,12 +19,10 @@ export async function sendWithRouting(options) {
19
19
  if (mode.suppressedReason === "dnd") {
20
20
  return { delivered: [], errors: [], suppressedReason: "DND active" };
21
21
  }
22
- if (mode.suppressedReason === "idle") {
23
- return { delivered: [], errors: [], suppressedReason: "Idle gated while active" };
24
- }
25
22
  const delivered = [];
26
23
  const errors = [];
27
- const desktopOnly = mode.desktopOnly;
24
+ // Idle gating downgrades to desktop-only; Slack (vsc_notif channel-log) is exempt below.
25
+ const desktopOnly = mode.desktopOnly || mode.suppressedReason === "idle";
28
26
  const trySend = async (name, fn) => {
29
27
  if (!fn)
30
28
  return;
@@ -54,7 +52,7 @@ export async function sendWithRouting(options) {
54
52
  if (!desktopOnly && enableDiscord) {
55
53
  await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
56
54
  }
57
- if (!desktopOnly && enableSlack) {
55
+ if (enableSlack) {
58
56
  await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
59
57
  }
60
58
  if (!desktopOnly && enableTeams) {