omni-notify-mcp 1.3.7 → 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 },
@@ -141,8 +145,52 @@ function mergePreservingSecrets(existing, update) {
141
145
  return merged;
142
146
  }
143
147
  const app = express();
144
- app.use(express.json());
148
+ app.use((req, _res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} UA:${req.headers['user-agent']?.slice(0, 60)}`); next(); });
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
+ }));
145
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
+ }
146
194
  const ntfySubscribers = new Map();
147
195
  function ntfyFanout(topic, message, title, priority, tags) {
148
196
  const subs = ntfySubscribers.get(topic);
@@ -205,6 +253,9 @@ function handleNtfyJson(req, res) {
205
253
  }, 30_000);
206
254
  req.on("close", () => { clearInterval(keepalive); ntfySubscribers.get(topic)?.delete(sub); });
207
255
  }
256
+ // ntfy health + info endpoints — app checks these before subscribing
257
+ app.get("/v1/health", (_req, res) => res.json({ healthy: true }));
258
+ app.get("/v1/info", (_req, res) => res.json({ version: "2.11.0", sha: "n/a" }));
208
259
  // ntfy app hits /:topic/sse or /:topic/json (no /ntfy/ prefix)
209
260
  app.get("/:topic/sse", (req, res) => {
210
261
  if (["api", "auth", "mcp", "assets", "ntfy"].includes(req.params.topic)) {
@@ -345,7 +396,8 @@ app.post("/api/test/tts", async (req, res) => {
345
396
  const voice = (typeof req.body?.voice === "string" && req.body.voice) ||
346
397
  cfg.desktop?.ttsVoice ||
347
398
  "en-US-AndrewMultilingualNeural";
348
- 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);
349
401
  res.json({ ok: true, message: `TTS played (${voice})` });
350
402
  }
351
403
  catch (err) {
@@ -772,33 +824,18 @@ function log(direction, channel, text, client) {
772
824
  }
773
825
  }
774
826
  app.get("/api/sessions", (_req, res) => {
775
- const list = listActiveSessions().map(s => ({
776
- clientId: s.clientId,
777
- tag: s.tag,
778
- clientName: s.clientName,
779
- clientVersion: s.clientVersion,
780
- workspaceName: s.workspaceName,
781
- host: s.host,
782
- connectedAt: s.connectedAt,
783
- 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,
784
834
  }));
785
835
  res.json({ sessions: list });
786
836
  });
787
- app.delete("/api/sessions/:clientId", (req, res) => {
788
- const { clientId } = req.params;
789
- const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
790
- if (!entry) {
791
- res.status(404).json({ error: "not found" });
792
- return;
793
- }
794
- const [sessionId] = entry;
795
- try {
796
- httpTransports[sessionId]?.close();
797
- }
798
- catch { /* ignore */ }
799
- delete httpTransports[sessionId];
800
- delete sessions[sessionId];
801
- 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." });
802
839
  });
803
840
  app.get("/api/logs", (req, res) => {
804
841
  res.setHeader("Content-Type", "text/event-stream");
@@ -813,198 +850,149 @@ app.get("/api/logs", (req, res) => {
813
850
  // ── Notification sender ───────────────────────────────────────────────────────
814
851
  async function sendNotification(message, priority, client) {
815
852
  const cfg = loadConfig();
816
- const results = [];
817
- const errors = [];
818
- // DND check — priority=high always bypasses; anything else gets dropped during quiet hours.
819
- // Email still goes through on "low" anyway (historical behavior: low=email-only).
820
- if (priority !== "high" && isDndActive(cfg)) {
821
- log("·", "dnd", `suppressed ${priority} notif (DND active)`, client);
822
- return "DND active — notif suppressed (priority=high would still send)";
823
- }
824
- // Idle gating — when the user is actively at the keyboard, suppress *remote*
825
- // channels (Telegram/SMS/email) for non-high priority. By default we still
826
- // play the desktop sound+banner so the user knows *something* happened —
827
- // they may have multiple agents running and this is a cheap local signal
828
- // that doesn't blast their phone. Disable via idle.alwaysDesktopWhenActive=false.
829
- // priority=high always bypasses idle entirely.
830
- // Conversation bypass: if the user just messaged us from Telegram (within
831
- // the TTL), they clearly want a reply over that channel, so skip idle gating.
832
853
  const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
833
- let desktopOnlyMode = false;
834
- // If the web UI is open and the user is actively watching it, skip remote channels.
835
- if (priority !== "high" && isUiActivelyOpen()) {
836
- if (cfg.idle?.alwaysDesktopWhenActive !== false && cfg.desktop?.enabled) {
837
- desktopOnlyMode = true;
838
- log("·", "ui", `UI visible — desktop-only`, client);
839
- }
840
- }
841
- if (!desktopOnlyMode && priority !== "high" && !inTelegramConvo && cfg.idle?.enabled !== false) {
842
- const idleSecs = getOsIdleSeconds();
843
- const threshold = cfg.idle?.thresholdSeconds ?? 120;
844
- const userIsActive = idleSecs >= 0 && idleSecs < threshold;
845
- if (userIsActive) {
846
- if (cfg.idle?.alwaysDesktopWhenActive !== false && cfg.desktop?.enabled) {
847
- desktopOnlyMode = true;
848
- log("·", "idle", `user active (${idleSecs}s < ${threshold}s) — desktop-only`, client);
849
- }
850
- else {
851
- log("·", "idle", `user active (${idleSecs}s < ${threshold}s) suppressed`, client);
852
- return "Idle gated — user is active, notif suppressed (priority=high would still send)";
853
- }
854
- }
855
- }
856
- else if (priority !== "high" && inTelegramConvo) {
857
- log("·", "idle", `bypassed (telegram convo active)`, client);
858
- }
859
- const send = async (name, fn) => {
860
- try {
861
- await fn();
862
- results.push(name);
863
- log("→", name, message, client);
864
- }
865
- catch (err) {
866
- const msg = err instanceof Error ? err.message : String(err);
867
- errors.push(`${name}: ${msg}`);
868
- log("→", name, `ERROR: ${msg}`, client);
869
- }
870
- };
871
- if (priority !== "low") {
872
- if (cfg.desktop?.enabled) {
873
- const wantSound = cfg.desktop?.sound !== false;
874
- // On Windows, SnoreToast's per-app sound is often muted by the user's
875
- // Windows notification settings — fire a PowerShell beep alongside the
876
- // toast so the audible cue is reliable. macOS/Linux: trust the OS.
877
- if (wantSound && process.platform === "win32") {
878
- // [console]::beep uses the PC speaker (motherboard buzzer), which
879
- // modern laptops/desktops don't have — silent on most machines.
880
- // SystemSounds.Asterisk plays through the actual sound card via
881
- // the Windows notification sound, audible on every machine.
882
- spawn("powershell", [
883
- "-NoProfile", "-Command",
884
- "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
885
- ], { windowsHide: true, stdio: "ignore" });
886
- }
887
- const soundOpt = wantSound && process.platform !== "win32";
888
- if (cfg.desktop?.tts) {
889
- const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
890
- speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
891
- }
892
- await send("desktop", () => new Promise((res, rej) => notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => err ? rej(err) : res())));
893
- }
894
- if (!desktopOnlyMode && cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
895
- 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 () => {
896
901
  const body = { chat_id: cfg.telegram.chatId, text: message };
897
902
  if (lastUserMessageId)
898
903
  body.reply_to_message_id = lastUserMessageId;
899
904
  const r = await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
900
- method: "POST", headers: { "Content-Type": "application/json" },
905
+ method: "POST",
906
+ headers: { "Content-Type": "application/json" },
901
907
  body: JSON.stringify(body),
902
908
  });
903
909
  if (!r.ok)
904
910
  throw new Error(await r.text());
905
- });
906
- }
907
- }
908
- if (priority === "high") {
909
- const sms = cfg.sms ?? {};
910
- if (sms.enabled && sms.accountSid && sms.authToken && sms.from && sms.to) {
911
- await send("sms", async () => {
912
- const client = twilio(sms.accountSid, sms.authToken);
913
- await client.messages.create({ body: message, from: sms.from, to: sms.to });
914
- });
915
- }
916
- }
917
- const email = cfg.email ?? {};
918
- if (!desktopOnlyMode && email.enabled && email.to) {
919
- await send("email", async () => {
920
- let transport;
921
- if (email.refreshToken && email.clientId && email.clientSecret) {
922
- transport = nodemailer.createTransport({
923
- service: "gmail", auth: { type: "OAuth2", user: email.connectedEmail ?? email.to,
924
- clientId: email.clientId, clientSecret: email.clientSecret,
925
- refreshToken: email.refreshToken, accessToken: email.accessToken },
926
- });
927
- }
928
- else if (email.host && email.user && email.pass) {
929
- transport = nodemailer.createTransport({
930
- host: email.host, port: email.port ?? 587, secure: email.secure ?? false,
931
- 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,
932
948
  });
933
- }
934
- else
935
- return;
936
- await transport.sendMail({ from: email.connectedEmail ?? email.user ?? email.to,
937
- to: email.to, subject: "Claude Notify", text: message });
938
- });
939
- }
940
- // ntfy (built-in server — direct fanout, no external fetch)
941
- if (!desktopOnlyMode) {
942
- const ntfy = cfg.ntfy ?? {};
943
- if (ntfy.enabled && ntfy.topic) {
944
- await send("ntfy", async () => {
949
+ },
950
+ ntfy: async (_text, prio) => {
945
951
  const priorityMap = { low: 2, normal: 3, high: 5 };
946
- const tags = priority === "high" ? "rotating_light" : "bell";
947
- 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;
948
954
  if (subs === 0)
949
- throw new Error(`ntfy: no subscribers on topic '${ntfy.topic}' — is the app connected?`);
950
- ntfyFanout(ntfy.topic, message, "Claude Notify", priorityMap[priority] ?? 3, tags);
951
- });
952
- }
953
- }
954
- // Discord
955
- if (!desktopOnlyMode) {
956
- const dc = cfg.discord ?? {};
957
- if (dc.enabled && dc.webhookUrl) {
958
- 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) => {
959
959
  const colorMap = { low: 0x6b7280, normal: 0x7c6dfa, high: 0xef4444 };
960
- const r = await fetch(dc.webhookUrl, {
960
+ const r = await fetch(cfg.discord.webhookUrl, {
961
961
  method: "POST",
962
962
  headers: { "Content-Type": "application/json" },
963
963
  body: JSON.stringify({
964
- username: dc.username ?? "Claude Notify",
964
+ username: cfg.discord.username ?? "Claude Notify",
965
965
  embeds: [{
966
966
  title: "Claude Notify",
967
967
  description: message,
968
- color: colorMap[priority] ?? colorMap.normal,
968
+ color: colorMap[prio] ?? colorMap.normal,
969
969
  timestamp: new Date().toISOString(),
970
970
  }],
971
971
  }),
972
972
  });
973
973
  if (!r.ok)
974
974
  throw new Error(`Discord ${r.status}: ${await r.text()}`);
975
- });
976
- }
977
- }
978
- // Slack
979
- if (!desktopOnlyMode) {
980
- const sl = cfg.slack ?? {};
981
- if (sl.enabled && sl.webhookUrl) {
982
- await send("slack", async () => {
975
+ },
976
+ slack: async (_text, prio) => {
983
977
  const emojiMap = { low: "ℹ️", normal: "🔔", high: "🚨" };
984
- const emoji = emojiMap[priority] ?? emojiMap.normal;
985
- const r = await fetch(sl.webhookUrl, {
978
+ const emoji = emojiMap[prio] ?? emojiMap.normal;
979
+ const r = await fetch(cfg.slack.webhookUrl, {
986
980
  method: "POST",
987
981
  headers: { "Content-Type": "application/json" },
988
982
  body: JSON.stringify({
989
983
  text: `${emoji} *Claude Notify*`,
990
984
  blocks: [
991
985
  { type: "section", text: { type: "mrkdwn", text: `${emoji} *Claude Notify*\n${message}` } },
992
- { type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${priority}` }] },
986
+ { type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${prio}` }] },
993
987
  ],
994
988
  }),
995
989
  });
996
990
  if (!r.ok)
997
991
  throw new Error(`Slack ${r.status}: ${await r.text()}`);
998
- });
999
- }
1000
- }
1001
- // Teams
1002
- if (!desktopOnlyMode) {
1003
- const tm = cfg.teams ?? {};
1004
- if (tm.enabled && tm.webhookUrl) {
1005
- await send("teams", async () => {
992
+ },
993
+ teams: async (_text, prio) => {
1006
994
  const colorMap = { low: "Default", normal: "Accent", high: "Attention" };
1007
- const r = await fetch(tm.webhookUrl, {
995
+ const r = await fetch(cfg.teams.webhookUrl, {
1008
996
  method: "POST",
1009
997
  headers: { "Content-Type": "application/json" },
1010
998
  body: JSON.stringify({
@@ -1014,11 +1002,12 @@ async function sendNotification(message, priority, client) {
1014
1002
  contentUrl: null,
1015
1003
  content: {
1016
1004
  $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
1017
- type: "AdaptiveCard", version: "1.2",
1005
+ type: "AdaptiveCard",
1006
+ version: "1.2",
1018
1007
  body: [
1019
- { 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" },
1020
1009
  { type: "TextBlock", text: message, wrap: true },
1021
- { type: "TextBlock", text: `Priority: ${priority}`, isSubtle: true, size: "Small" },
1010
+ { type: "TextBlock", text: `Priority: ${prio}`, isSubtle: true, size: "Small" },
1022
1011
  ],
1023
1012
  },
1024
1013
  }],
@@ -1026,12 +1015,22 @@ async function sendNotification(message, priority, client) {
1026
1015
  });
1027
1016
  if (!r.ok)
1028
1017
  throw new Error(`Teams ${r.status}: ${await r.text()}`);
1029
- });
1030
- }
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);
1031
1030
  }
1032
1031
  return [
1033
- results.length ? `Sent via: ${results.join(", ")}` : null,
1034
- 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,
1035
1034
  ].filter(Boolean).join(" | ") || "No channels delivered";
1036
1035
  }
1037
1036
  // ── OS idle-time (cross-platform) ─────────────────────────────────────────────
@@ -1169,8 +1168,11 @@ function writeInboxDrop(entry) {
1169
1168
  const header = `# Unsolicited user message\n\n` +
1170
1169
  `- Time: ${entry.ts}\n` +
1171
1170
  (entry.tag ? `- Tag: @${entry.tag}\n` : "") +
1172
- `- Origin: user (out-of-band)\n\n`;
1173
- 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);
1174
1176
  }
1175
1177
  catch (err) {
1176
1178
  log("·", "inbox-drop", `write failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -1221,6 +1223,22 @@ function broadcastInbox(entry) {
1221
1223
  }
1222
1224
  return delivered;
1223
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
+ }
1224
1242
  // Test-only: inject a fake inbox entry exactly as the Telegram listener would.
1225
1243
  // Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
1226
1244
  // production run. Used by the test suite to drive wait_for_inbox wake-up and
@@ -1234,20 +1252,8 @@ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
1234
1252
  return;
1235
1253
  }
1236
1254
  const entry = { text, ts: new Date().toISOString(), tag };
1237
- const waiters = takeWaitersFor(tag);
1238
- if (waiters.length > 0) {
1239
- for (const w of waiters) {
1240
- clearTimeout(w.timer);
1241
- w.resolve([entry]);
1242
- }
1243
- }
1244
- else {
1245
- inboxQueue.push(entry);
1246
- }
1247
- const sse = broadcastInbox(entry);
1248
- writeInboxDrop(entry);
1249
- log("·", "test-inject", `${text} (waiters=${waiters.length}, sse=${sse})`, tag);
1250
- 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 });
1251
1257
  });
1252
1258
  log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox enabled");
1253
1259
  }
@@ -1274,6 +1280,206 @@ app.get("/api/inbox/stream", (req, res) => {
1274
1280
  inboxStreamClients.delete(client);
1275
1281
  });
1276
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
+ });
1277
1483
  async function initTgOffset(token) {
1278
1484
  const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
1279
1485
  const json = await r.json();
@@ -1369,28 +1575,19 @@ async function startTelegramListener() {
1369
1575
  writeInboxDrop(entry);
1370
1576
  const liveSseCount = broadcastInbox(entry);
1371
1577
  log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
1372
- // Before building the ack, prune sessions whose transport stream
1373
- // is dead or whose heartbeat has lapsed. Without this the ack
1374
- // cheerfully claims "broadcast to 3 sessions" when 2 of them are
1375
- // closed VS Code windows — which is exactly what prompted this fix.
1376
- pruneDeadSessions();
1377
- // Build an ack that names the active sessions the user's message
1378
- // is being routed to. If the user tagged it and no session with
1379
- // that tag is connected, tell them plainly so they don't sit
1380
- // waiting for a reply that can't come.
1381
- const targets = sessionsMatchingTag(tag);
1578
+ const sseCount = sseSubscribersForTag(tag);
1579
+ const anyoneListening = sseCount > 0 || waiters.length > 0;
1382
1580
  let ackText;
1383
- if (tag && targets.length === 0) {
1581
+ if (!anyoneListening && tag) {
1384
1582
  ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
1385
1583
  }
1386
- else if (targets.length === 0) {
1584
+ else if (!anyoneListening) {
1387
1585
  ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
1388
1586
  }
1389
1587
  else {
1390
- const names = targets.map(sessionDisplay).join(", ");
1391
1588
  ackText = tag
1392
- ? `📬 Routed to ${names}. Waiting for them to reply.`
1393
- : `📬 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.`;
1394
1591
  }
1395
1592
  fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
1396
1593
  method: "POST", headers: { "Content-Type": "application/json" },
@@ -1416,6 +1613,158 @@ async function startTelegramListener() {
1416
1613
  }
1417
1614
  }
1418
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
+ }
1419
1768
  app.get("/reply/:token", (req, res) => {
1420
1769
  const pending = pendingAsks.get(req.params.token);
1421
1770
  res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
@@ -1886,6 +2235,22 @@ function sessionsMatchingTag(tag) {
1886
2235
  return listActiveSessions();
1887
2236
  return listActiveSessions().filter(s => s.tag === tag);
1888
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
+ }
1889
2254
  // Synchronous best-effort liveness check before we count sessions in an ack.
1890
2255
  // The transport's SDK doesn't expose a "ping" API, but it does hold a ref to
1891
2256
  // the response stream of the last GET the client opened — if that stream is
@@ -1922,6 +2287,13 @@ function sessionDisplay(s) {
1922
2287
  return s.tag ? `@${s.tag}` : s.clientId;
1923
2288
  }
1924
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
+ }
1925
2297
  console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
1926
2298
  const existingSessionId = req.headers["mcp-session-id"];
1927
2299
  if (existingSessionId && httpTransports[existingSessionId]) {
@@ -2055,8 +2427,14 @@ function trackReconnect(clientId) {
2055
2427
  const httpServer = app.listen(PORT, "0.0.0.0", () => {
2056
2428
  const ip = getLocalIp();
2057
2429
  console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
2058
- 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
+ }
2059
2436
  startTelegramListener();
2437
+ startSlackListener();
2060
2438
  open(`http://localhost:${PORT}`).catch(() => { });
2061
2439
  });
2062
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.7",
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",
package/ui/public/app.js CHANGED
@@ -83,7 +83,7 @@ function populateForm() {
83
83
  $("ntfy-enabled").checked = !!ntfy.enabled;
84
84
  $("ntfy-topic").value = ntfy.topic ?? "";
85
85
  const defaultUrl = `${location.protocol}//${location.hostname}:${location.port || (location.protocol === 'https:' ? 443 : 80)}`;
86
- $("ntfy-server-url").value = ntfy.serverUrl || defaultUrl;
86
+ $("ntfy-server-url").value = (ntfy.serverUrl || defaultUrl).replace(/\/ntfy\/?$/, "");
87
87
 
88
88
  // Discord
89
89
  const dc = config.discord ?? {};
@@ -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">