omni-notify-mcp 1.3.8 → 1.3.11

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
@@ -6,9 +6,9 @@
6
6
 
7
7
  <p align="center">
8
8
  <em>Reach me on any channel. Ask me anything. Get out of my way when I'm busy.</em><br>
9
- An MCP server that gives AI agents (Claude, Cursor, etc.) a single
10
- <code>notify</code> / <code>ask</code> interface desktop, Telegram, SMS, email
11
- with two-way replies, idle gating, and Do Not Disturb.
9
+ HTTP-first notification/control server with optional MCP compatibility for AI agents
10
+ (Claude, Copilot, Cursor, etc.): desktop, Telegram, Slack, SMS, email,
11
+ two-way replies, idle gating, and Do Not Disturb.
12
12
  </p>
13
13
 
14
14
  <p align="center">
@@ -37,6 +37,20 @@ You step away from your machine and the AI is still working. **It needs to**:
37
37
 
38
38
  ## Quick start
39
39
 
40
+ ```bash
41
+ npx omni-notify-ui
42
+ ```
43
+
44
+ This starts the HTTP/UI server on `http://localhost:3737`.
45
+
46
+ MCP is optional. To enable the `/mcp` endpoint, start with:
47
+
48
+ ```bash
49
+ ENABLE_MCP=1 npx omni-notify-ui
50
+ ```
51
+
52
+ Or use the stdio bridge (which auto-spawns the UI with MCP enabled):
53
+
40
54
  ```bash
41
55
  npx omni-notify-mcp
42
56
  ```
@@ -54,12 +68,6 @@ Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_co
54
68
  }
55
69
  ```
56
70
 
57
- Then run the config UI to wire up your channels:
58
-
59
- ```bash
60
- npx omni-notify-ui
61
- ```
62
-
63
71
  Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
64
72
 
65
73
  ## What the agent gets
package/dist/index.js CHANGED
@@ -27,12 +27,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
27
27
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
28
28
  import { spawn } from "child_process";
29
29
  import { existsSync } from "fs";
30
- import { join, dirname } from "path";
30
+ import { join, dirname, basename } from "path";
31
+ import { hostname } from "os";
31
32
  import { fileURLToPath } from "url";
32
33
  import { z } from "zod";
33
34
  const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
34
35
  const BASE = `http://localhost:${PORT}`;
35
- const SESSION_TAG = (process.env.NOTIFY_MCP_TAG ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
36
+ const VSC_ID = (process.env.NOTIFY_MCP_TAG || basename(process.cwd())).toLowerCase().replace(/[^a-z0-9_-]/g, "");
37
+ const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}`;
36
38
  const CLIENT_NAME = "claude-channel-bridge";
37
39
  // ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
38
40
  async function serverIsUp() {
@@ -43,9 +45,9 @@ async function serverIsUp() {
43
45
  body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
44
46
  signal: AbortSignal.timeout(1500),
45
47
  });
46
- // Any HTTP response (including 400/406 from malformed init) means the
47
- // server is alive and speaking HTTP. Real "down" surfaces as a throw.
48
- return r.status > 0;
48
+ // The bridge requires an active /mcp endpoint.
49
+ // 404 specifically means UI may be up but MCP transport is disabled.
50
+ return r.status > 0 && r.status !== 404;
49
51
  }
50
52
  catch {
51
53
  return false;
@@ -68,7 +70,7 @@ function spawnUiServerIfNeeded() {
68
70
  const child = spawn(process.execPath, [uiPath], {
69
71
  detached: true,
70
72
  stdio: "ignore",
71
- env: { ...process.env, PORT: String(PORT) },
73
+ env: { ...process.env, PORT: String(PORT), ENABLE_MCP: "1" },
72
74
  });
73
75
  child.unref();
74
76
  }
@@ -159,6 +161,21 @@ async function httpInitialize() {
159
161
  // Follow-up: the spec requires a notifications/initialized after initialize.
160
162
  await httpRpc("notifications/initialized").catch(() => { });
161
163
  }
164
+ // Periodically touch our /mcp session so the server's reaper doesn't prune it.
165
+ // Without this, the bridge opens a session on startup, proxies tool calls
166
+ // infrequently, and after 60s of idle the server wipes the session from its
167
+ // sessions[] map — which makes Telegram's "routed to X agents" ack say
168
+ // "no agents connected" even though the bridge is still alive and subscribed.
169
+ function startSessionKeepalive() {
170
+ setInterval(async () => {
171
+ try {
172
+ await httpRpc("tools/list").catch(() => { });
173
+ }
174
+ catch {
175
+ // best effort; next call will re-initialize via 404 retry path
176
+ }
177
+ }, 30_000);
178
+ }
162
179
  // ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
163
180
  const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
164
181
  // Declare the claude/channel capability so Claude Code knows to surface
@@ -168,10 +185,14 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
168
185
  capabilities: {
169
186
  experimental: { "claude/channel": {} },
170
187
  },
171
- instructions: "This is the stdio bridge for notify-mcp. It pushes unsolicited user " +
172
- "messages to the agent via `notifications/claude/channel` when the host " +
173
- "supports Channels (Claude Code v2.1.80+). Otherwise call `wait_for_inbox` " +
174
- "as a long-poll to reliably receive user messages as tool results.\n\n" +
188
+ instructions: "This is the stdio bridge for notify-mcp. To receive unsolicited user " +
189
+ "messages in ANY MCP host (Claude Code, Copilot, Cursor), call `wait_for_inbox` " +
190
+ " it long-polls up to 50s and returns new user messages as tool results. Call " +
191
+ "it whenever you finish a task or would otherwise wait, then again right after " +
192
+ "handling each batch, so messages keep arriving. (The Claude Code CLI launched " +
193
+ "with `--channels` ALSO delivers messages as synthetic turns automatically, but " +
194
+ "that path does NOT exist in the VSCode extension or Copilot — `wait_for_inbox` " +
195
+ "is the reliable lowest-common-denominator mechanism across every host.)\n\n" +
175
196
  "ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
176
197
  "just finished (success or failure), (b) you have a question or need a " +
177
198
  "decision, (c) something important happened the user needs to know right " +
@@ -182,6 +203,15 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
182
203
  "shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
183
204
  "SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
184
205
  "Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
206
+ "🚨 500-CHAR LIMIT — CHUNK, NEVER TRUNCATE 🚨\n" +
207
+ "The `notify` tool rejects bodies > 500 chars with `MCP error -32602: too_big`. " +
208
+ "When you have more to say than fits in 500 chars, you MUST split into MULTIPLE " +
209
+ "notify calls — do NOT silently shorten the body. Procedure: (1) decide what " +
210
+ "the user MUST see (every fact, file path, line number, recommendation); (2) if " +
211
+ "the body exceeds 500 chars, split into N chunks numbered '(1/N) ...', '(2/N) ...', " +
212
+ "each ≤ 500 chars including the prefix; (3) send all N chunks in order via separate " +
213
+ "notify calls and echo all N IN FULL in chat. NEVER respond to a `too_big` error by " +
214
+ "shortening to a single chunk — that loses information the user explicitly needs.\n\n" +
185
215
  "When the user asks you to remember a behavioral rule or change how you should act, " +
186
216
  "call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
187
217
  "so the instructions persist across sessions and context compaction.",
@@ -200,7 +230,10 @@ async function proxyToolCall(name, args) {
200
230
  }
201
231
  return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
202
232
  }
203
- server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured.", {
233
+ server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
234
+ "MAX 500 CHARS PER MESSAGE. If you have more to say, split into multiple notify " +
235
+ "calls with '(1/N) ...', '(2/N) ...' prefixes — never silently shorten on " +
236
+ "`too_big` error; that loses information the user needs.", {
204
237
  message: z.string().max(500),
205
238
  priority: z.enum(["low", "normal", "high"]).default("normal"),
206
239
  }, async (args) => proxyToolCall("notify", args));
@@ -323,6 +356,7 @@ async function main() {
323
356
  // Fire-and-forget: the stdio transport should be usable immediately; the
324
357
  // push channel attaches as soon as the SSE handshake completes.
325
358
  subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
359
+ startSessionKeepalive();
326
360
  const transport = new StdioServerTransport();
327
361
  await server.connect(transport);
328
362
  stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
@@ -0,0 +1,67 @@
1
+ export function computeDesktopOnlyMode(priority, policy, ctx) {
2
+ if (priority === "high") {
3
+ return { desktopOnly: false };
4
+ }
5
+ if (policy.dndActive) {
6
+ return { desktopOnly: false, suppressedReason: "dnd" };
7
+ }
8
+ if (ctx.uiActive || (policy.idleEnabled && !ctx.inTelegramConversation && ctx.idleSeconds >= 0 && ctx.idleSeconds < policy.idleThresholdSeconds)) {
9
+ if (policy.alwaysDesktopWhenActive) {
10
+ return { desktopOnly: true };
11
+ }
12
+ return { desktopOnly: false, suppressedReason: "idle" };
13
+ }
14
+ return { desktopOnly: false };
15
+ }
16
+ export async function sendWithRouting(options) {
17
+ const { message, priority, senders, policy, ctx, enableDesktop, enableTelegram, enableEmail, enableSms, enableNtfy, enableDiscord, enableSlack, enableTeams, } = options;
18
+ const mode = computeDesktopOnlyMode(priority, policy, ctx);
19
+ if (mode.suppressedReason === "dnd") {
20
+ return { delivered: [], errors: [], suppressedReason: "DND active" };
21
+ }
22
+ if (mode.suppressedReason === "idle") {
23
+ return { delivered: [], errors: [], suppressedReason: "Idle gated while active" };
24
+ }
25
+ const delivered = [];
26
+ const errors = [];
27
+ const desktopOnly = mode.desktopOnly;
28
+ const trySend = async (name, fn) => {
29
+ if (!fn)
30
+ return;
31
+ try {
32
+ await fn();
33
+ delivered.push(name);
34
+ }
35
+ catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ errors.push(`${name}: ${msg}`);
38
+ }
39
+ };
40
+ if (priority !== "low") {
41
+ if (enableDesktop) {
42
+ await trySend("desktop", senders.desktop ? () => senders.desktop(message) : undefined);
43
+ }
44
+ if (!desktopOnly && enableTelegram) {
45
+ await trySend("telegram", senders.telegram ? () => senders.telegram(message) : undefined);
46
+ }
47
+ }
48
+ if (!desktopOnly && enableEmail) {
49
+ await trySend("email", senders.email ? () => senders.email(message) : undefined);
50
+ }
51
+ if (!desktopOnly && enableNtfy) {
52
+ await trySend("ntfy", senders.ntfy ? () => senders.ntfy(message, priority) : undefined);
53
+ }
54
+ if (!desktopOnly && enableDiscord) {
55
+ await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
56
+ }
57
+ if (!desktopOnly && enableSlack) {
58
+ await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
59
+ }
60
+ if (!desktopOnly && enableTeams) {
61
+ await trySend("teams", senders.teams ? () => senders.teams(message, priority) : undefined);
62
+ }
63
+ if (priority === "high" && enableSms) {
64
+ await trySend("sms", senders.sms ? () => senders.sms(message) : undefined);
65
+ }
66
+ return { delivered, errors };
67
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/ui/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from "crypto";
2
+ import { randomUUID, createHmac, timingSafeEqual } from "crypto";
3
3
  import express from "express";
4
4
  import { google } from "googleapis";
5
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
@@ -11,16 +11,20 @@ import open from "open";
11
11
  import notifier from "node-notifier";
12
12
  import nodemailer from "nodemailer";
13
13
  import twilio from "twilio";
14
+ import { tmpdir } from "os";
15
+ import { sendWithRouting } from "./messaging/notificationEngine.js";
16
+ import { z } from "zod";
14
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
18
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16
- import { z } from "zod";
17
- import { tmpdir } from "os";
18
19
  const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
19
20
  const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
20
21
  const PUBLIC_DIR = join(fileURLToPath(new URL("../../ui/public", import.meta.url)));
21
22
  const CONFIG_DIR = join(homedir(), ".notify-mcp");
22
23
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
23
24
  const ADC_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
25
+ const AGENT_API_KEY = (process.env.NOTIFY_AGENT_KEY ?? "").trim();
26
+ const SLACK_SIGNING_SECRET = (process.env.SLACK_SIGNING_SECRET ?? "").trim();
27
+ const ENABLE_MCP = (process.env.ENABLE_MCP ?? "").trim() === "1";
24
28
  function defaultConfig() {
25
29
  return {
26
30
  desktop: { enabled: false, sound: true },
@@ -142,8 +146,51 @@ function mergePreservingSecrets(existing, update) {
142
146
  }
143
147
  const app = express();
144
148
  app.use((req, _res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} UA:${req.headers['user-agent']?.slice(0, 60)}`); next(); });
145
- app.use(express.json());
149
+ app.use(express.json({
150
+ verify: (req, _res, buf) => {
151
+ req.rawBody = Buffer.from(buf);
152
+ },
153
+ }));
154
+ app.use(express.urlencoded({
155
+ extended: true,
156
+ verify: (req, _res, buf) => {
157
+ const r = req;
158
+ if (!r.rawBody)
159
+ r.rawBody = Buffer.from(buf);
160
+ },
161
+ }));
146
162
  app.use(express.static(PUBLIC_DIR));
163
+ function requireAgentAuth(req, res) {
164
+ if (!AGENT_API_KEY)
165
+ return true;
166
+ const got = String(req.headers["x-notify-key"] ?? "").trim();
167
+ if (!got || got !== AGENT_API_KEY) {
168
+ res.status(401).json({ error: "unauthorized" });
169
+ return false;
170
+ }
171
+ return true;
172
+ }
173
+ function verifySlackSignature(req) {
174
+ if (!SLACK_SIGNING_SECRET)
175
+ return true;
176
+ const sig = String(req.headers["x-slack-signature"] ?? "");
177
+ const ts = String(req.headers["x-slack-request-timestamp"] ?? "");
178
+ if (!sig || !ts)
179
+ return false;
180
+ const tsNum = parseInt(ts, 10);
181
+ if (!Number.isFinite(tsNum))
182
+ return false;
183
+ if (Math.abs(Math.floor(Date.now() / 1000) - tsNum) > 300)
184
+ return false;
185
+ const rawBody = (req.rawBody ?? Buffer.from("{}")).toString("utf8");
186
+ const base = `v0:${ts}:${rawBody}`;
187
+ const expected = `v0=${createHmac("sha256", SLACK_SIGNING_SECRET).update(base).digest("hex")}`;
188
+ const a = Buffer.from(expected, "utf8");
189
+ const b = Buffer.from(sig, "utf8");
190
+ if (a.length !== b.length)
191
+ return false;
192
+ return timingSafeEqual(a, b);
193
+ }
147
194
  const ntfySubscribers = new Map();
148
195
  function ntfyFanout(topic, message, title, priority, tags) {
149
196
  const subs = ntfySubscribers.get(topic);
@@ -349,7 +396,8 @@ app.post("/api/test/tts", async (req, res) => {
349
396
  const voice = (typeof req.body?.voice === "string" && req.body.voice) ||
350
397
  cfg.desktop?.ttsVoice ||
351
398
  "en-US-AndrewMultilingualNeural";
352
- await speakText("Notification from Claude. This is a voice test.", voice);
399
+ const text = (typeof req.body?.text === "string" && req.body.text.trim()) || "Notification from Claude. This is a voice test.";
400
+ await speakText(text, voice);
353
401
  res.json({ ok: true, message: `TTS played (${voice})` });
354
402
  }
355
403
  catch (err) {
@@ -776,33 +824,18 @@ function log(direction, channel, text, client) {
776
824
  }
777
825
  }
778
826
  app.get("/api/sessions", (_req, res) => {
779
- const list = listActiveSessions().map(s => ({
780
- clientId: s.clientId,
781
- tag: s.tag,
782
- clientName: s.clientName,
783
- clientVersion: s.clientVersion,
784
- workspaceName: s.workspaceName,
785
- host: s.host,
786
- connectedAt: s.connectedAt,
787
- lastSeen: s.lastSeen,
827
+ const now = Date.now();
828
+ const list = [...inboxStreamClients].map((c, i) => ({
829
+ clientId: `sse-${i + 1}`,
830
+ tag: c.tag,
831
+ transport: "sse",
832
+ connectedAt: now,
833
+ lastSeen: now,
788
834
  }));
789
835
  res.json({ sessions: list });
790
836
  });
791
- app.delete("/api/sessions/:clientId", (req, res) => {
792
- const { clientId } = req.params;
793
- const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
794
- if (!entry) {
795
- res.status(404).json({ error: "not found" });
796
- return;
797
- }
798
- const [sessionId] = entry;
799
- try {
800
- httpTransports[sessionId]?.close();
801
- }
802
- catch { /* ignore */ }
803
- delete httpTransports[sessionId];
804
- delete sessions[sessionId];
805
- res.json({ ok: true });
837
+ app.delete("/api/sessions/:clientId", (_req, res) => {
838
+ res.status(410).json({ error: "session_disconnect_unsupported", message: "HTTP mode does not support remote disconnect." });
806
839
  });
807
840
  app.get("/api/logs", (req, res) => {
808
841
  res.setHeader("Content-Type", "text/event-stream");
@@ -817,198 +850,149 @@ app.get("/api/logs", (req, res) => {
817
850
  // ── Notification sender ───────────────────────────────────────────────────────
818
851
  async function sendNotification(message, priority, client) {
819
852
  const cfg = loadConfig();
820
- const results = [];
821
- const errors = [];
822
- // DND check — priority=high always bypasses; anything else gets dropped during quiet hours.
823
- // Email still goes through on "low" anyway (historical behavior: low=email-only).
824
- if (priority !== "high" && isDndActive(cfg)) {
825
- log("·", "dnd", `suppressed ${priority} notif (DND active)`, client);
826
- return "DND active — notif suppressed (priority=high would still send)";
827
- }
828
- // Idle gating — when the user is actively at the keyboard, suppress *remote*
829
- // channels (Telegram/SMS/email) for non-high priority. By default we still
830
- // play the desktop sound+banner so the user knows *something* happened —
831
- // they may have multiple agents running and this is a cheap local signal
832
- // that doesn't blast their phone. Disable via idle.alwaysDesktopWhenActive=false.
833
- // priority=high always bypasses idle entirely.
834
- // Conversation bypass: if the user just messaged us from Telegram (within
835
- // the TTL), they clearly want a reply over that channel, so skip idle gating.
836
853
  const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
837
- let desktopOnlyMode = false;
838
- // If the web UI is open and the user is actively watching it, skip remote channels.
839
- if (priority !== "high" && isUiActivelyOpen()) {
840
- if (cfg.idle?.alwaysDesktopWhenActive !== false && cfg.desktop?.enabled) {
841
- desktopOnlyMode = true;
842
- log("·", "ui", `UI visible — desktop-only`, client);
843
- }
844
- }
845
- if (!desktopOnlyMode && priority !== "high" && !inTelegramConvo && cfg.idle?.enabled !== false) {
846
- const idleSecs = getOsIdleSeconds();
847
- const threshold = cfg.idle?.thresholdSeconds ?? 120;
848
- const userIsActive = idleSecs >= 0 && idleSecs < threshold;
849
- if (userIsActive) {
850
- if (cfg.idle?.alwaysDesktopWhenActive !== false && cfg.desktop?.enabled) {
851
- desktopOnlyMode = true;
852
- log("·", "idle", `user active (${idleSecs}s < ${threshold}s) — desktop-only`, client);
853
- }
854
- else {
855
- log("·", "idle", `user active (${idleSecs}s < ${threshold}s) suppressed`, client);
856
- return "Idle gated — user is active, notif suppressed (priority=high would still send)";
857
- }
858
- }
859
- }
860
- else if (priority !== "high" && inTelegramConvo) {
861
- log("·", "idle", `bypassed (telegram convo active)`, client);
862
- }
863
- const send = async (name, fn) => {
864
- try {
865
- await fn();
866
- results.push(name);
867
- log("→", name, message, client);
868
- }
869
- catch (err) {
870
- const msg = err instanceof Error ? err.message : String(err);
871
- errors.push(`${name}: ${msg}`);
872
- log("→", name, `ERROR: ${msg}`, client);
873
- }
874
- };
875
- if (priority !== "low") {
876
- if (cfg.desktop?.enabled) {
877
- const wantSound = cfg.desktop?.sound !== false;
878
- // On Windows, SnoreToast's per-app sound is often muted by the user's
879
- // Windows notification settings — fire a PowerShell beep alongside the
880
- // toast so the audible cue is reliable. macOS/Linux: trust the OS.
881
- if (wantSound && process.platform === "win32") {
882
- // [console]::beep uses the PC speaker (motherboard buzzer), which
883
- // modern laptops/desktops don't have — silent on most machines.
884
- // SystemSounds.Asterisk plays through the actual sound card via
885
- // the Windows notification sound, audible on every machine.
886
- spawn("powershell", [
887
- "-NoProfile", "-Command",
888
- "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
889
- ], { windowsHide: true, stdio: "ignore" });
890
- }
891
- const soundOpt = wantSound && process.platform !== "win32";
892
- if (cfg.desktop?.tts) {
893
- const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
894
- speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
895
- }
896
- await send("desktop", () => new Promise((res, rej) => notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => err ? rej(err) : res())));
897
- }
898
- if (!desktopOnlyMode && cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
899
- await send("telegram", async () => {
854
+ const idleSecs = getOsIdleSeconds();
855
+ const result = await sendWithRouting({
856
+ message,
857
+ priority,
858
+ policy: {
859
+ idleEnabled: cfg.idle?.enabled !== false,
860
+ idleThresholdSeconds: cfg.idle?.thresholdSeconds ?? 120,
861
+ alwaysDesktopWhenActive: cfg.idle?.alwaysDesktopWhenActive !== false,
862
+ dndActive: isDndActive(cfg),
863
+ },
864
+ ctx: {
865
+ inTelegramConversation: inTelegramConvo,
866
+ uiActive: isUiActivelyOpen(),
867
+ idleSeconds: idleSecs,
868
+ },
869
+ enableDesktop: !!cfg.desktop?.enabled,
870
+ enableTelegram: !!(cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId),
871
+ enableEmail: !!(cfg.email?.enabled && cfg.email.to),
872
+ enableSms: !!(cfg.sms?.enabled && cfg.sms.accountSid && cfg.sms.authToken && cfg.sms.from && cfg.sms.to),
873
+ enableNtfy: !!(cfg.ntfy?.enabled && cfg.ntfy.topic),
874
+ enableDiscord: !!(cfg.discord?.enabled && cfg.discord.webhookUrl),
875
+ enableSlack: !!(cfg.slack?.enabled && cfg.slack.webhookUrl),
876
+ enableTeams: !!(cfg.teams?.enabled && cfg.teams.webhookUrl),
877
+ senders: {
878
+ desktop: async () => {
879
+ const wantSound = cfg.desktop?.sound !== false;
880
+ if (wantSound && process.platform === "win32") {
881
+ spawn("powershell", [
882
+ "-NoProfile", "-Command",
883
+ "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
884
+ ], { windowsHide: true, stdio: "ignore" });
885
+ }
886
+ const soundOpt = wantSound && process.platform !== "win32";
887
+ if (cfg.desktop?.tts) {
888
+ const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
889
+ speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
890
+ }
891
+ await new Promise((resolve, reject) => {
892
+ notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => {
893
+ if (err)
894
+ reject(err);
895
+ else
896
+ resolve();
897
+ });
898
+ });
899
+ },
900
+ telegram: async () => {
900
901
  const body = { chat_id: cfg.telegram.chatId, text: message };
901
902
  if (lastUserMessageId)
902
903
  body.reply_to_message_id = lastUserMessageId;
903
904
  const r = await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
904
- method: "POST", headers: { "Content-Type": "application/json" },
905
+ method: "POST",
906
+ headers: { "Content-Type": "application/json" },
905
907
  body: JSON.stringify(body),
906
908
  });
907
909
  if (!r.ok)
908
910
  throw new Error(await r.text());
909
- });
910
- }
911
- }
912
- if (priority === "high") {
913
- const sms = cfg.sms ?? {};
914
- if (sms.enabled && sms.accountSid && sms.authToken && sms.from && sms.to) {
915
- await send("sms", async () => {
916
- const client = twilio(sms.accountSid, sms.authToken);
917
- await client.messages.create({ body: message, from: sms.from, to: sms.to });
918
- });
919
- }
920
- }
921
- const email = cfg.email ?? {};
922
- if (!desktopOnlyMode && email.enabled && email.to) {
923
- await send("email", async () => {
924
- let transport;
925
- if (email.refreshToken && email.clientId && email.clientSecret) {
926
- transport = nodemailer.createTransport({
927
- service: "gmail", auth: { type: "OAuth2", user: email.connectedEmail ?? email.to,
928
- clientId: email.clientId, clientSecret: email.clientSecret,
929
- refreshToken: email.refreshToken, accessToken: email.accessToken },
930
- });
931
- }
932
- else if (email.host && email.user && email.pass) {
933
- transport = nodemailer.createTransport({
934
- host: email.host, port: email.port ?? 587, secure: email.secure ?? false,
935
- auth: { user: email.user, pass: email.pass },
911
+ },
912
+ sms: async () => {
913
+ const smsClient = twilio(cfg.sms.accountSid, cfg.sms.authToken);
914
+ await smsClient.messages.create({ body: message, from: cfg.sms.from, to: cfg.sms.to });
915
+ },
916
+ email: async () => {
917
+ const email = cfg.email;
918
+ let transport;
919
+ if (email.refreshToken && email.clientId && email.clientSecret) {
920
+ transport = nodemailer.createTransport({
921
+ service: "gmail",
922
+ auth: {
923
+ type: "OAuth2",
924
+ user: email.connectedEmail ?? email.to,
925
+ clientId: email.clientId,
926
+ clientSecret: email.clientSecret,
927
+ refreshToken: email.refreshToken,
928
+ accessToken: email.accessToken,
929
+ },
930
+ });
931
+ }
932
+ else if (email.host && email.user && email.pass) {
933
+ transport = nodemailer.createTransport({
934
+ host: email.host,
935
+ port: email.port ?? 587,
936
+ secure: email.secure ?? false,
937
+ auth: { user: email.user, pass: email.pass },
938
+ });
939
+ }
940
+ else {
941
+ return;
942
+ }
943
+ await transport.sendMail({
944
+ from: email.connectedEmail ?? email.user ?? email.to,
945
+ to: email.to,
946
+ subject: "Claude Notify",
947
+ text: message,
936
948
  });
937
- }
938
- else
939
- return;
940
- await transport.sendMail({ from: email.connectedEmail ?? email.user ?? email.to,
941
- to: email.to, subject: "Claude Notify", text: message });
942
- });
943
- }
944
- // ntfy (built-in server — direct fanout, no external fetch)
945
- if (!desktopOnlyMode) {
946
- const ntfy = cfg.ntfy ?? {};
947
- if (ntfy.enabled && ntfy.topic) {
948
- await send("ntfy", async () => {
949
+ },
950
+ ntfy: async (_text, prio) => {
949
951
  const priorityMap = { low: 2, normal: 3, high: 5 };
950
- const tags = priority === "high" ? "rotating_light" : "bell";
951
- const subs = ntfySubscribers.get(ntfy.topic)?.size ?? 0;
952
+ const tags = prio === "high" ? "rotating_light" : "bell";
953
+ const subs = ntfySubscribers.get(cfg.ntfy.topic)?.size ?? 0;
952
954
  if (subs === 0)
953
- throw new Error(`ntfy: no subscribers on topic '${ntfy.topic}' — is the app connected?`);
954
- ntfyFanout(ntfy.topic, message, "Claude Notify", priorityMap[priority] ?? 3, tags);
955
- });
956
- }
957
- }
958
- // Discord
959
- if (!desktopOnlyMode) {
960
- const dc = cfg.discord ?? {};
961
- if (dc.enabled && dc.webhookUrl) {
962
- await send("discord", async () => {
955
+ throw new Error(`ntfy: no subscribers on topic '${cfg.ntfy.topic}'`);
956
+ ntfyFanout(cfg.ntfy.topic, message, "Claude Notify", priorityMap[prio] ?? 3, tags);
957
+ },
958
+ discord: async (_text, prio) => {
963
959
  const colorMap = { low: 0x6b7280, normal: 0x7c6dfa, high: 0xef4444 };
964
- const r = await fetch(dc.webhookUrl, {
960
+ const r = await fetch(cfg.discord.webhookUrl, {
965
961
  method: "POST",
966
962
  headers: { "Content-Type": "application/json" },
967
963
  body: JSON.stringify({
968
- username: dc.username ?? "Claude Notify",
964
+ username: cfg.discord.username ?? "Claude Notify",
969
965
  embeds: [{
970
966
  title: "Claude Notify",
971
967
  description: message,
972
- color: colorMap[priority] ?? colorMap.normal,
968
+ color: colorMap[prio] ?? colorMap.normal,
973
969
  timestamp: new Date().toISOString(),
974
970
  }],
975
971
  }),
976
972
  });
977
973
  if (!r.ok)
978
974
  throw new Error(`Discord ${r.status}: ${await r.text()}`);
979
- });
980
- }
981
- }
982
- // Slack
983
- if (!desktopOnlyMode) {
984
- const sl = cfg.slack ?? {};
985
- if (sl.enabled && sl.webhookUrl) {
986
- await send("slack", async () => {
975
+ },
976
+ slack: async (_text, prio) => {
987
977
  const emojiMap = { low: "ℹ️", normal: "🔔", high: "🚨" };
988
- const emoji = emojiMap[priority] ?? emojiMap.normal;
989
- const r = await fetch(sl.webhookUrl, {
978
+ const emoji = emojiMap[prio] ?? emojiMap.normal;
979
+ const r = await fetch(cfg.slack.webhookUrl, {
990
980
  method: "POST",
991
981
  headers: { "Content-Type": "application/json" },
992
982
  body: JSON.stringify({
993
983
  text: `${emoji} *Claude Notify*`,
994
984
  blocks: [
995
985
  { type: "section", text: { type: "mrkdwn", text: `${emoji} *Claude Notify*\n${message}` } },
996
- { type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${priority}` }] },
986
+ { type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${prio}` }] },
997
987
  ],
998
988
  }),
999
989
  });
1000
990
  if (!r.ok)
1001
991
  throw new Error(`Slack ${r.status}: ${await r.text()}`);
1002
- });
1003
- }
1004
- }
1005
- // Teams
1006
- if (!desktopOnlyMode) {
1007
- const tm = cfg.teams ?? {};
1008
- if (tm.enabled && tm.webhookUrl) {
1009
- await send("teams", async () => {
992
+ },
993
+ teams: async (_text, prio) => {
1010
994
  const colorMap = { low: "Default", normal: "Accent", high: "Attention" };
1011
- const r = await fetch(tm.webhookUrl, {
995
+ const r = await fetch(cfg.teams.webhookUrl, {
1012
996
  method: "POST",
1013
997
  headers: { "Content-Type": "application/json" },
1014
998
  body: JSON.stringify({
@@ -1018,11 +1002,12 @@ async function sendNotification(message, priority, client) {
1018
1002
  contentUrl: null,
1019
1003
  content: {
1020
1004
  $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
1021
- type: "AdaptiveCard", version: "1.2",
1005
+ type: "AdaptiveCard",
1006
+ version: "1.2",
1022
1007
  body: [
1023
- { type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[priority] ?? "Default" },
1008
+ { type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[prio] ?? "Default" },
1024
1009
  { type: "TextBlock", text: message, wrap: true },
1025
- { type: "TextBlock", text: `Priority: ${priority}`, isSubtle: true, size: "Small" },
1010
+ { type: "TextBlock", text: `Priority: ${prio}`, isSubtle: true, size: "Small" },
1026
1011
  ],
1027
1012
  },
1028
1013
  }],
@@ -1030,12 +1015,22 @@ async function sendNotification(message, priority, client) {
1030
1015
  });
1031
1016
  if (!r.ok)
1032
1017
  throw new Error(`Teams ${r.status}: ${await r.text()}`);
1033
- });
1034
- }
1018
+ },
1019
+ },
1020
+ });
1021
+ if (result.suppressedReason) {
1022
+ log("·", "notify", `suppressed (${result.suppressedReason})`, client);
1023
+ return result.suppressedReason;
1024
+ }
1025
+ for (const delivered of result.delivered) {
1026
+ log("→", delivered, message, client);
1027
+ }
1028
+ for (const err of result.errors) {
1029
+ log("→", "notify", `ERROR: ${err}`, client);
1035
1030
  }
1036
1031
  return [
1037
- results.length ? `Sent via: ${results.join(", ")}` : null,
1038
- errors.length ? `Errors: ${errors.join("; ")}` : null,
1032
+ result.delivered.length ? `Sent via: ${result.delivered.join(", ")}` : null,
1033
+ result.errors.length ? `Errors: ${result.errors.join("; ")}` : null,
1039
1034
  ].filter(Boolean).join(" | ") || "No channels delivered";
1040
1035
  }
1041
1036
  // ── OS idle-time (cross-platform) ─────────────────────────────────────────────
@@ -1173,8 +1168,11 @@ function writeInboxDrop(entry) {
1173
1168
  const header = `# Unsolicited user message\n\n` +
1174
1169
  `- Time: ${entry.ts}\n` +
1175
1170
  (entry.tag ? `- Tag: @${entry.tag}\n` : "") +
1176
- `- Origin: user (out-of-band)\n\n`;
1177
- writeFileSync(path, header + entry.text + "\n");
1171
+ `- Origin: ${entry.origin ?? "user"} (out-of-band)\n\n`;
1172
+ const replyHint = entry.origin === "slack"
1173
+ ? `\n---\n↩ This arrived over the Slack bus — the user is WAITING in the channel. Reply there ASAP when done:\n\`\`\`\ncurl -s -X POST http://localhost:${PORT}/api/agent/slack/reply -H 'Content-Type: application/json' -d '{"text":"YOUR ANSWER","tag":"${entry.tag ?? ""}"}'\n\`\`\`\n`
1174
+ : "";
1175
+ writeFileSync(path, header + entry.text + "\n" + replyHint);
1178
1176
  }
1179
1177
  catch (err) {
1180
1178
  log("·", "inbox-drop", `write failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1225,6 +1223,22 @@ function broadcastInbox(entry) {
1225
1223
  }
1226
1224
  return delivered;
1227
1225
  }
1226
+ function ingestInboxEntry(entry, source) {
1227
+ const waiters = takeWaitersFor(entry.tag);
1228
+ if (waiters.length > 0) {
1229
+ for (const w of waiters) {
1230
+ clearTimeout(w.timer);
1231
+ w.resolve([entry]);
1232
+ }
1233
+ }
1234
+ else {
1235
+ inboxQueue.push(entry);
1236
+ writeInboxDrop(entry);
1237
+ }
1238
+ const sse = broadcastInbox(entry);
1239
+ log("·", "inbox", `${source}: ${entry.text} (sse=${sse}, waiters=${waiters.length})`, entry.tag);
1240
+ return { waiters: waiters.length, sse };
1241
+ }
1228
1242
  // Test-only: inject a fake inbox entry exactly as the Telegram listener would.
1229
1243
  // Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
1230
1244
  // production run. Used by the test suite to drive wait_for_inbox wake-up and
@@ -1238,20 +1252,8 @@ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
1238
1252
  return;
1239
1253
  }
1240
1254
  const entry = { text, ts: new Date().toISOString(), tag };
1241
- const waiters = takeWaitersFor(tag);
1242
- if (waiters.length > 0) {
1243
- for (const w of waiters) {
1244
- clearTimeout(w.timer);
1245
- w.resolve([entry]);
1246
- }
1247
- }
1248
- else {
1249
- inboxQueue.push(entry);
1250
- }
1251
- const sse = broadcastInbox(entry);
1252
- writeInboxDrop(entry);
1253
- log("·", "test-inject", `${text} (waiters=${waiters.length}, sse=${sse})`, tag);
1254
- res.json({ injected: true, waiters: waiters.length, sse });
1255
+ const out = ingestInboxEntry(entry, "test-inject");
1256
+ res.json({ injected: true, waiters: out.waiters, sse: out.sse });
1255
1257
  });
1256
1258
  log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox enabled");
1257
1259
  }
@@ -1278,6 +1280,206 @@ app.get("/api/inbox/stream", (req, res) => {
1278
1280
  inboxStreamClients.delete(client);
1279
1281
  });
1280
1282
  });
1283
+ // ── Non-MCP automation API ───────────────────────────────────────────────────
1284
+ // These endpoints let an agent script use plain HTTP (no MCP transport) for
1285
+ // notify + unsolicited inbox handling.
1286
+ app.post("/api/agent/notify", async (req, res) => {
1287
+ if (!requireAgentAuth(req, res))
1288
+ return;
1289
+ const message = String(req.body?.message ?? "").trim();
1290
+ const priority = String(req.body?.priority ?? "normal");
1291
+ if (!message) {
1292
+ res.status(400).json({ error: "message required" });
1293
+ return;
1294
+ }
1295
+ if (!["low", "normal", "high"].includes(priority)) {
1296
+ res.status(400).json({ error: "invalid priority" });
1297
+ return;
1298
+ }
1299
+ try {
1300
+ const out = await sendNotification(message, priority, "agent-http");
1301
+ res.json({ ok: true, result: out });
1302
+ }
1303
+ catch (err) {
1304
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
1305
+ }
1306
+ });
1307
+ app.get("/api/agent/inbox/poll", (req, res) => {
1308
+ if (!requireAgentAuth(req, res))
1309
+ return;
1310
+ const tag = typeof req.query.tag === "string" ? req.query.tag.toLowerCase() : undefined;
1311
+ const messages = drainInboxFor(tag);
1312
+ res.json({ ok: true, messages });
1313
+ });
1314
+ app.get("/api/agent/inbox/wait", async (req, res) => {
1315
+ if (!requireAgentAuth(req, res))
1316
+ return;
1317
+ const tag = typeof req.query.tag === "string" ? req.query.tag.toLowerCase() : undefined;
1318
+ const timeoutSecondsRaw = parseInt(String(req.query.timeout_seconds ?? "50"), 10);
1319
+ const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) ? Math.max(5, Math.min(55, timeoutSecondsRaw)) : 50;
1320
+ const queued = drainInboxFor(tag);
1321
+ if (queued.length > 0) {
1322
+ res.json({ ok: true, messages: queued, empty: false });
1323
+ return;
1324
+ }
1325
+ const token = randomUUID();
1326
+ const entries = await new Promise((resolve) => {
1327
+ const timer = setTimeout(() => {
1328
+ inboxWaiters.delete(token);
1329
+ resolve([]);
1330
+ }, timeoutSeconds * 1000);
1331
+ inboxWaiters.set(token, { resolve, timer, tag });
1332
+ });
1333
+ res.json({ ok: true, messages: entries, empty: entries.length === 0 });
1334
+ });
1335
+ app.post("/api/agent/inbox/inject", (req, res) => {
1336
+ if (!requireAgentAuth(req, res))
1337
+ return;
1338
+ const text = String(req.body?.text ?? "").trim();
1339
+ if (!text) {
1340
+ res.status(400).json({ error: "text required" });
1341
+ return;
1342
+ }
1343
+ const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
1344
+ const entry = { text, ts: new Date().toISOString(), tag };
1345
+ const out = ingestInboxEntry(entry, "agent-inject");
1346
+ res.json({ ok: true, waiters: out.waiters, sse: out.sse });
1347
+ });
1348
+ app.post("/api/agent/slack/reply", (req, res) => {
1349
+ if (!requireAgentAuth(req, res))
1350
+ return;
1351
+ const text = String(req.body?.text ?? "").trim();
1352
+ if (!text) {
1353
+ res.status(400).json({ error: "text required" });
1354
+ return;
1355
+ }
1356
+ const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
1357
+ slackPost(tag ? `[@${tag}] ${text}` : text);
1358
+ log("→", "slack:reply", text, tag);
1359
+ res.json({ ok: true });
1360
+ });
1361
+ // Interactive-session busy/idle, reported by that session's hooks (busy on
1362
+ // UserPromptSubmit/PreToolUse/PostToolUse, idle on Stop). Lets the bus tell the
1363
+ // user "Claude is busy" + a rough ETA when a request lands on a busy prompt.
1364
+ const sessionBusy = {};
1365
+ const busyDurations = [];
1366
+ function setSessionState(tag, busy) {
1367
+ const prev = sessionBusy[tag];
1368
+ if (busy) {
1369
+ if (!prev?.busy)
1370
+ sessionBusy[tag] = { busy: true, since: Date.now() };
1371
+ }
1372
+ else {
1373
+ if (prev?.busy) {
1374
+ busyDurations.push(Date.now() - prev.since);
1375
+ if (busyDurations.length > 20)
1376
+ busyDurations.shift();
1377
+ }
1378
+ sessionBusy[tag] = { busy: false, since: 0 };
1379
+ }
1380
+ }
1381
+ function busyEtaSecs() {
1382
+ if (!busyDurations.length)
1383
+ return null;
1384
+ return Math.round(busyDurations.reduce((a, b) => a + b, 0) / busyDurations.length / 1000);
1385
+ }
1386
+ function sessionBusyNote(tag) {
1387
+ const s = sessionBusy[tag];
1388
+ if (!s?.busy)
1389
+ return "";
1390
+ const secs = Math.round((Date.now() - s.since) / 1000);
1391
+ const eta = busyEtaSecs();
1392
+ return `🔧 Claude @${tag} is busy right now (working ${secs}s) — your request is queued and runs the moment the prompt is free.${eta ? ` Turns here usually finish in ~${eta}s.` : ""}`;
1393
+ }
1394
+ app.post("/api/session/state", (req, res) => {
1395
+ const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
1396
+ if (!tag) {
1397
+ res.status(400).json({ error: "tag required" });
1398
+ return;
1399
+ }
1400
+ setSessionState(tag, req.body?.busy === true || req.body?.busy === "true");
1401
+ res.json({ ok: true, busy: sessionBusy[tag]?.busy ?? false });
1402
+ });
1403
+ // Slack Events API (inbound). Requires configuring your Slack app with an
1404
+ // Event Request URL: POST /api/slack/events.
1405
+ app.post("/api/slack/events", (req, res) => {
1406
+ let body = req.body ?? {};
1407
+ if (body?.payload && typeof body.payload === "string") {
1408
+ try {
1409
+ body = JSON.parse(body.payload);
1410
+ }
1411
+ catch {
1412
+ // keep original body if payload isn't valid JSON
1413
+ }
1414
+ }
1415
+ if ((!body || Object.keys(body).length === 0) && req.rawBody) {
1416
+ const raw = (req.rawBody ?? Buffer.from("{}")).toString("utf8");
1417
+ try {
1418
+ body = JSON.parse(raw);
1419
+ }
1420
+ catch {
1421
+ body = req.body ?? {};
1422
+ }
1423
+ }
1424
+ // Slack URL verification can arrive before full event wiring, and some
1425
+ // intermediaries/proxies send this as form data. Respond immediately when
1426
+ // a challenge value is present so the URL can be saved.
1427
+ const challenge = body?.challenge ?? (typeof req.query.challenge === "string" ? req.query.challenge : undefined);
1428
+ if (challenge) {
1429
+ log("·", "slack", "url_verification challenge received");
1430
+ res.status(200).json({ challenge });
1431
+ return;
1432
+ }
1433
+ if (!verifySlackSignature(req)) {
1434
+ log("·", "slack", "rejected: bad signature");
1435
+ res.status(401).json({ error: "bad slack signature" });
1436
+ return;
1437
+ }
1438
+ const envelopeType = String(body?.type ?? "unknown");
1439
+ const event = body?.event ?? {};
1440
+ const eventType = String(event?.type ?? "none");
1441
+ const subtype = typeof event?.subtype === "string" ? event.subtype : "";
1442
+ const channel = String(event?.channel ?? body?.channel_id ?? "none");
1443
+ log("·", "slack", `event: envelope=${envelopeType}, type=${eventType}, subtype=${subtype || "none"}, channel=${channel}`);
1444
+ if (body.type !== "event_callback") {
1445
+ res.json({ ok: true, ignored: true, reason: `unsupported envelope type: ${envelopeType}` });
1446
+ return;
1447
+ }
1448
+ const ignoreReasons = [];
1449
+ if (event.type !== "message")
1450
+ ignoreReasons.push(`event.type=${eventType}`);
1451
+ if (subtype)
1452
+ ignoreReasons.push(`subtype=${subtype}`);
1453
+ if (event.bot_id)
1454
+ ignoreReasons.push("bot message");
1455
+ if (!event.text)
1456
+ ignoreReasons.push("missing text");
1457
+ if (event.type !== "message" || subtype || event.bot_id || !event.text) {
1458
+ const reason = ignoreReasons.join(", ") || "filtered";
1459
+ log("·", "slack", `ignored: ${reason}`);
1460
+ res.json({ ok: true, ignored: true, reason });
1461
+ return;
1462
+ }
1463
+ const parsed = parseTag(String(event.text));
1464
+ const candidate = [...pendingAsks.entries()].find(([, p]) => (parsed.tag ? p.tag === parsed.tag : true));
1465
+ if (candidate) {
1466
+ const [id, pending] = candidate;
1467
+ clearTimeout(pending.timer);
1468
+ pendingAsks.delete(id);
1469
+ log("←", "ask:reply", parsed.text, parsed.tag);
1470
+ pending.resolve(parsed.text);
1471
+ res.json({ ok: true, routed: "ask" });
1472
+ return;
1473
+ }
1474
+ const ts = event.ts ? new Date(Number(event.ts) * 1000).toISOString() : new Date().toISOString();
1475
+ const entry = {
1476
+ text: parsed.text,
1477
+ tag: parsed.tag,
1478
+ ts,
1479
+ };
1480
+ const out = ingestInboxEntry(entry, "slack");
1481
+ res.json({ ok: true, routed: "inbox", waiters: out.waiters, sse: out.sse });
1482
+ });
1281
1483
  async function initTgOffset(token) {
1282
1484
  const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
1283
1485
  const json = await r.json();
@@ -1373,28 +1575,19 @@ async function startTelegramListener() {
1373
1575
  writeInboxDrop(entry);
1374
1576
  const liveSseCount = broadcastInbox(entry);
1375
1577
  log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
1376
- // Before building the ack, prune sessions whose transport stream
1377
- // is dead or whose heartbeat has lapsed. Without this the ack
1378
- // cheerfully claims "broadcast to 3 sessions" when 2 of them are
1379
- // closed VS Code windows — which is exactly what prompted this fix.
1380
- pruneDeadSessions();
1381
- // Build an ack that names the active sessions the user's message
1382
- // is being routed to. If the user tagged it and no session with
1383
- // that tag is connected, tell them plainly so they don't sit
1384
- // waiting for a reply that can't come.
1385
- const targets = sessionsMatchingTag(tag);
1578
+ const sseCount = sseSubscribersForTag(tag);
1579
+ const anyoneListening = sseCount > 0 || waiters.length > 0;
1386
1580
  let ackText;
1387
- if (tag && targets.length === 0) {
1581
+ if (!anyoneListening && tag) {
1388
1582
  ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
1389
1583
  }
1390
- else if (targets.length === 0) {
1584
+ else if (!anyoneListening) {
1391
1585
  ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
1392
1586
  }
1393
1587
  else {
1394
- const names = targets.map(sessionDisplay).join(", ");
1395
1588
  ackText = tag
1396
- ? `📬 Routed to ${names}. Waiting for them to reply.`
1397
- : `📬 Broadcast to ${targets.length} session(s): ${names}. Each should reply with its identity respond to the one you want.`;
1589
+ ? `📬 Routed to @${tag}. Waiting for reply.`
1590
+ : `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`;
1398
1591
  }
1399
1592
  fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1400
1593
  method: "POST", headers: { "Content-Type": "application/json" },
@@ -1420,6 +1613,158 @@ async function startTelegramListener() {
1420
1613
  }
1421
1614
  }
1422
1615
  }
1616
+ // ── Slack inbound poller (always-on; folds slack-poll.sh → #16) ────────────────
1617
+ const SLACK_SECRETS_PATH = join(fileURLToPath(new URL("../../notify-secrets.json", import.meta.url)));
1618
+ function decodeB64Fields(obj) {
1619
+ if (Array.isArray(obj))
1620
+ return obj.map(decodeB64Fields);
1621
+ if (obj && typeof obj === "object") {
1622
+ const out = {};
1623
+ for (const [k, v] of Object.entries(obj)) {
1624
+ if (k.endsWith("_b64") && typeof v === "string")
1625
+ out[k.slice(0, -4)] = Buffer.from(v, "base64").toString("utf8");
1626
+ else
1627
+ out[k] = decodeB64Fields(v);
1628
+ }
1629
+ return out;
1630
+ }
1631
+ return obj;
1632
+ }
1633
+ function loadSecrets() {
1634
+ if (!existsSync(SLACK_SECRETS_PATH))
1635
+ return {};
1636
+ try {
1637
+ return decodeB64Fields(JSON.parse(readFileSync(SLACK_SECRETS_PATH, "utf8")));
1638
+ }
1639
+ catch (err) {
1640
+ log("·", "secrets", `load failed: ${err instanceof Error ? err.message : String(err)}`);
1641
+ return {};
1642
+ }
1643
+ }
1644
+ function slackCreds() {
1645
+ const s = loadSecrets().slack ?? {};
1646
+ const cfgSlack = loadConfig().slack ?? {};
1647
+ const token = (process.env.SLACK_BOT_TOKEN ?? s.botToken ?? "").trim() || undefined;
1648
+ const channel = (process.env.SLACK_CHANNEL_ID ?? s.channelId ?? "").trim() || undefined;
1649
+ const webhook = (s.webhookUrl ?? cfgSlack.webhookUrl ?? "").trim() || undefined;
1650
+ return { token, channel, webhook };
1651
+ }
1652
+ async function slackPost(text) {
1653
+ const { webhook } = slackCreds();
1654
+ if (!webhook)
1655
+ return;
1656
+ try {
1657
+ await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
1658
+ }
1659
+ catch { /* webhook post is best-effort */ }
1660
+ }
1661
+ function slackClientTags() {
1662
+ const tags = new Set();
1663
+ for (const sess of listActiveSessions())
1664
+ if (sess.tag)
1665
+ tags.add(sess.tag);
1666
+ for (const c of inboxStreamClients)
1667
+ if (c.tag)
1668
+ tags.add(c.tag);
1669
+ for (const [, w] of inboxWaiters)
1670
+ if (w.tag)
1671
+ tags.add(w.tag);
1672
+ return [...tags].sort();
1673
+ }
1674
+ function slackClientsNumbered() {
1675
+ const tags = slackClientTags();
1676
+ return tags.length ? tags.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none connected)";
1677
+ }
1678
+ function resolveSlackClient(handle) {
1679
+ const tags = slackClientTags();
1680
+ if (/^[0-9]+$/.test(handle))
1681
+ return tags[parseInt(handle, 10) - 1];
1682
+ return tags.find(t => t === handle);
1683
+ }
1684
+ async function handleSlackCommand(lc) {
1685
+ if (lc === "list clients" || lc === "clients" || lc === "list") {
1686
+ await slackPost(`Connected clients — reply @<name> or #<id>:\n${slackClientsNumbered()}`);
1687
+ return true;
1688
+ }
1689
+ if (lc === "help" || lc === "commands" || lc === "?") {
1690
+ await slackPost("Commands: `clients`. Direct: `@<name> msg`, `#<id> msg`, or untagged → broadcast to all.");
1691
+ return true;
1692
+ }
1693
+ return false;
1694
+ }
1695
+ let slackCursor = "";
1696
+ let slackConsecutiveErrors = 0;
1697
+ async function pollSlackOnce(token, channel) {
1698
+ const url = `https://slack.com/api/conversations.history?channel=${encodeURIComponent(channel)}&oldest=${encodeURIComponent(slackCursor)}&limit=50`;
1699
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
1700
+ const json = await r.json();
1701
+ if (!json.ok)
1702
+ throw new Error(`conversations.history: ${json.error ?? "unknown"}`);
1703
+ const all = json.messages ?? [];
1704
+ const human = all
1705
+ .filter(m => !m.subtype && !m.bot_id && !m.app_id && m.user && String(m.text ?? "").trim())
1706
+ .sort((a, b) => Number(a.ts) - Number(b.ts));
1707
+ for (const m of human) {
1708
+ const text = String(m.text ?? "");
1709
+ const stripped = text.replace(/<@[A-Za-z0-9]+>/g, "");
1710
+ const lc = stripped.toLowerCase().trim();
1711
+ if (await handleSlackCommand(lc)) {
1712
+ log("←", "slack:cmd", lc);
1713
+ continue;
1714
+ }
1715
+ const clean = stripped.replace(/^\s+/, "");
1716
+ const routed = clean.match(/^[@#]([^\s]+)\s+([\s\S]*)$/);
1717
+ if (routed) {
1718
+ const handle = routed[1];
1719
+ const msg = routed[2];
1720
+ const tag = resolveSlackClient(handle);
1721
+ if (!tag) {
1722
+ await slackPost(`❌ Unknown client "${handle}". Connected:\n${slackClientsNumbered()}`);
1723
+ log("←", "slack", `unknown client: ${handle}`);
1724
+ continue;
1725
+ }
1726
+ ingestInboxEntry({ text: msg, ts: new Date().toISOString(), tag, origin: "slack" }, "slack");
1727
+ await slackPost(sessionBusyNote(tag) || "ack");
1728
+ }
1729
+ else {
1730
+ ingestInboxEntry({ text, ts: new Date().toISOString(), origin: "slack" }, "slack");
1731
+ await slackPost("ack");
1732
+ }
1733
+ }
1734
+ const newest = all.reduce((acc, m) => (Number(m.ts) > Number(acc || 0) ? String(m.ts) : acc), slackCursor);
1735
+ if (newest)
1736
+ slackCursor = newest;
1737
+ }
1738
+ async function startSlackListener() {
1739
+ const interval = (Number(process.env.SLACK_POLL_INTERVAL) || 2) * 1000;
1740
+ while (true) {
1741
+ try {
1742
+ const { token, channel } = slackCreds();
1743
+ if (!token || !channel) {
1744
+ await new Promise(r => setTimeout(r, 5000));
1745
+ continue;
1746
+ }
1747
+ if (!slackCursor) {
1748
+ slackCursor = String(Math.floor(Date.now() / 1000) - 300);
1749
+ log("·", "slack", `listener ready, channel=${channel}, backfill=300s (closes restart gap)`);
1750
+ }
1751
+ await pollSlackOnce(token, channel);
1752
+ if (slackConsecutiveErrors > 0) {
1753
+ log("·", "slack", `recovered after ${slackConsecutiveErrors} attempt(s)`);
1754
+ slackConsecutiveErrors = 0;
1755
+ }
1756
+ await new Promise(r => setTimeout(r, interval));
1757
+ }
1758
+ catch (err) {
1759
+ const msg = err instanceof Error ? err.message : String(err);
1760
+ if (!msg.includes("terminated") && !msg.includes("aborted"))
1761
+ log("·", "slack:error", msg);
1762
+ slackConsecutiveErrors++;
1763
+ const delay = Math.min(60_000, 2000 * Math.pow(2, Math.min(5, slackConsecutiveErrors - 1)));
1764
+ await new Promise(r => setTimeout(r, delay));
1765
+ }
1766
+ }
1767
+ }
1423
1768
  app.get("/reply/:token", (req, res) => {
1424
1769
  const pending = pendingAsks.get(req.params.token);
1425
1770
  res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
@@ -1890,6 +2235,22 @@ function sessionsMatchingTag(tag) {
1890
2235
  return listActiveSessions();
1891
2236
  return listActiveSessions().filter(s => s.tag === tag);
1892
2237
  }
2238
+ // Count live SSE subscribers on /api/inbox/stream that would receive a message
2239
+ // with the given tag. Stdio-bridge clients subscribe via SSE but don't always
2240
+ // appear in sessions[] (their /mcp initialize session can get reaped while the
2241
+ // SSE stream stays alive). Without counting them, the Telegram ack lies with
2242
+ // "no agents connected" even when a bridge is actively listening.
2243
+ function sseSubscribersForTag(tag) {
2244
+ let n = 0;
2245
+ for (const c of inboxStreamClients) {
2246
+ if (c.res.destroyed || c.res.writableEnded || !c.res.writable)
2247
+ continue;
2248
+ if (tag && c.tag !== tag)
2249
+ continue;
2250
+ n++;
2251
+ }
2252
+ return n;
2253
+ }
1893
2254
  // Synchronous best-effort liveness check before we count sessions in an ack.
1894
2255
  // The transport's SDK doesn't expose a "ping" API, but it does hold a ref to
1895
2256
  // the response stream of the last GET the client opened — if that stream is
@@ -1926,6 +2287,13 @@ function sessionDisplay(s) {
1926
2287
  return s.tag ? `@${s.tag}` : s.clientId;
1927
2288
  }
1928
2289
  app.all("/mcp", async (req, res) => {
2290
+ if (!ENABLE_MCP) {
2291
+ res.status(404).json({
2292
+ error: "mcp_disabled",
2293
+ message: "MCP transport is disabled. Set ENABLE_MCP=1 to enable /mcp.",
2294
+ });
2295
+ return;
2296
+ }
1929
2297
  console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
1930
2298
  const existingSessionId = req.headers["mcp-session-id"];
1931
2299
  if (existingSessionId && httpTransports[existingSessionId]) {
@@ -2059,8 +2427,14 @@ function trackReconnect(clientId) {
2059
2427
  const httpServer = app.listen(PORT, "0.0.0.0", () => {
2060
2428
  const ip = getLocalIp();
2061
2429
  console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
2062
- console.log(` MCP endpoint (remote) http://${ip}:${PORT}/mcp\n`);
2430
+ if (ENABLE_MCP) {
2431
+ console.log(` MCP endpoint (remote) → http://${ip}:${PORT}/mcp\n`);
2432
+ }
2433
+ else {
2434
+ console.log(" MCP endpoint (remote) → disabled (set ENABLE_MCP=1 to enable)\n");
2435
+ }
2063
2436
  startTelegramListener();
2437
+ startSlackListener();
2064
2438
  open(`http://localhost:${PORT}`).catch(() => { });
2065
2439
  });
2066
2440
  // TCP-level keepalive on every incoming socket. Without this, a client that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.3.8",
3
+ "version": "1.3.11",
4
4
  "description": "An MCP server that lets AI agents (Claude, Cursor, etc.) reach you on any channel — desktop, Telegram, SMS, email — with two-way ask/reply, real-time inbox push, Do Not Disturb, idle gating, multi-session routing, and a one-page web UI for setup. Zero config code; configure once, agents call notify/ask.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  <div class="tabs">
41
41
  <button class="tab tab-active" data-tab="claude-code">Claude Code (CLI)</button>
42
42
  <button class="tab" data-tab="cursor">Cursor</button>
43
- <button class="tab" data-tab="vscode">VS Code</button>
43
+ <button class="tab" data-tab="vscode">VS Code + Copilot</button>
44
44
  <button class="tab" data-tab="claude-desktop">Claude Desktop</button>
45
45
  <button class="tab" data-tab="windsurf">Windsurf</button>
46
46
  <button class="tab" data-tab="zed">Zed</button>
@@ -125,6 +125,7 @@
125
125
  }</code></pre>
126
126
  </div>
127
127
  <p class="hint">Or via Command Palette → <em>MCP: Add Server</em> → npm package → <code>omni-notify-mcp</code>.</p>
128
+ <p class="hint"><strong>Important for GitHub Copilot Chat/Agent:</strong> Copilot can miss server-pushed notifications. For reliable inbound user messages, the agent should loop on <code>wait_for_inbox(timeout_seconds=50)</code> and handle tool results immediately.</p>
128
129
  </div>
129
130
 
130
131
  <!-- Claude Desktop -->
@@ -230,16 +231,18 @@
230
231
 
231
232
  <section class="help-section">
232
233
  <h2>What the AI gets</h2>
233
- <p>Six tools, all server-configured. The agent never names a channel — it just calls <code>notify</code>/<code>ask</code> and your settings decide where it goes:</p>
234
+ <p>Eight tools, all server-configured. The agent never names a channel — it just calls <code>notify</code>/<code>ask</code> and your settings decide where it goes:</p>
234
235
  <table class="tools-table">
235
236
  <tr><td><code>notify</code></td><td>Send a message. <code>priority=low|normal|high</code> controls fan-out and bypasses DND when high.</td></tr>
236
237
  <tr><td><code>ask</code></td><td>Send a question and <strong>wait for your reply</strong> (Telegram or email link).</td></tr>
237
238
  <tr><td><code>poll</code></td><td>Drain unsolicited messages you sent the agent.</td></tr>
239
+ <tr><td><code>wait_for_inbox</code></td><td>Long-poll up to 50s and return immediately when a new inbound user message exists.</td></tr>
238
240
  <tr><td><code>get_idle_seconds</code></td><td>Seconds since your last keyboard/mouse input.</td></tr>
239
241
  <tr><td><code>get_idle_config</code></td><td>The server's idle-gating policy.</td></tr>
240
242
  <tr><td><code>get_dnd_status</code></td><td>Whether Do Not Disturb is currently suppressing.</td></tr>
243
+ <tr><td><code>reply</code></td><td>Channel return-path for stdio clients that support pushed channel events.</td></tr>
241
244
  </table>
242
- <p class="hint">Well-behaved agents pre-flight with <code>get_idle_seconds</code> and skip the notif entirely if you're at the keyboard. The MCP <code>instructions</code> field tells every connecting client this no per-prompt prodding required.</p>
245
+ <p class="hint">For Copilot, treat <code>wait_for_inbox</code> as the primary inbound path. Keep a tight loop and process every non-empty result before continuing long tasks.</p>
243
246
  </section>
244
247
 
245
248
  <section class="help-section">