omni-notify-mcp 1.3.14 → 1.3.21

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/dist/ui/server.js CHANGED
@@ -12,16 +12,15 @@ import notifier from "node-notifier";
12
12
  import nodemailer from "nodemailer";
13
13
  import { PinpointSMSVoiceV2Client, SendTextMessageCommand, DescribePhoneNumbersCommand, DescribeVerifiedDestinationNumbersCommand } from "@aws-sdk/client-pinpoint-sms-voice-v2";
14
14
  import { tmpdir } from "os";
15
- import { sendWithRouting } from "./messaging/notificationEngine.js";
15
+ import { sendWithRouting, idleReading, formatIdleReading, describePriorityRouting } from "./messaging/notificationEngine.js";
16
16
  import { z } from "zod";
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
19
  const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
20
20
  const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
21
21
  const SLACK_REDIRECT_URI = `http://localhost:${PORT}/auth/slack/callback`;
22
- // Bot scopes requested during the one-click OAuth connect. chat:write.public
23
- // lets the bot post to public channels without being invited first.
24
- const SLACK_OAUTH_SCOPES = "channels:read,groups:read,chat:write,chat:write.public";
22
+ // GATE: the Slack methods a one-click bot token may call — conversations.list (channel picker), conversations.history (bus poller), chat.postMessage (notifications, public channels without an invite); a method outside this set answers missing_scope
23
+ const SLACK_OAUTH_SCOPES = "channels:read,groups:read,channels:history,groups:history,chat:write,chat:write.public";
25
24
  const DISCORD_REDIRECT_URI = `http://localhost:${PORT}/auth/discord/callback`;
26
25
  // webhook.incoming makes Discord return a ready-to-use channel webhook on Authorize.
27
26
  const DISCORD_OAUTH_SCOPE = "webhook.incoming";
@@ -38,7 +37,6 @@ function defaultConfig() {
38
37
  disabledClients: [],
39
38
  desktop: { enabled: false, sound: true },
40
39
  telegram: { enabled: false, token: "", chatIds: [] },
41
- whatsapp: { enabled: false, instanceId: "", apiToken: "", phone: "" },
42
40
  sms: { enabled: false, accessKeyId: "", secretAccessKey: "", region: "us-east-1", originationNumber: "", to: [] },
43
41
  email: { enabled: false, to: "" },
44
42
  ntfy: { enabled: false, topic: "", serverUrl: "" },
@@ -140,6 +138,15 @@ function displayTag(tag) {
140
138
  return clientAliasMap()[tag] || tag;
141
139
  }
142
140
  const MASKED = "••••••••";
141
+ // Credentials and secrets are runtime state, never source-relative: under a
142
+ // global/`npx` install the compiled server lives in a package directory that
143
+ // contains no secrets file at all, so the file MUST be found by the same
144
+ // CONFIG_DIR resolution the config uses. `NOTIFY_MCP_SECRETS_PATH` overrides.
145
+ const SECRETS_PATH = process.env.NOTIFY_MCP_SECRETS_PATH
146
+ || join(CONFIG_DIR, "notify-secrets.json");
147
+ // Backward compatibility for an existing per-user install: older builds,
148
+ // `setup-secrets.sh` and the README all placed the file at `~/.notify-mcp-secrets`.
149
+ const LEGACY_SECRETS_PATH = join(homedir(), ".notify-mcp-secrets");
143
150
  // AWS SendTextMessage requires E.164 (e.g. +14089812202) — strip spaces, dashes,
144
151
  // parens, dots so a user-entered "+1 408 981 2202" is accepted.
145
152
  function e164(s) {
@@ -159,8 +166,6 @@ function maskSecrets(config) {
159
166
  c.sms.secretAccessKey = MASKED;
160
167
  if (c.telegram?.token)
161
168
  c.telegram.token = MASKED;
162
- if (c.whatsapp?.apiToken)
163
- c.whatsapp.apiToken = MASKED;
164
169
  if (c.discord?.webhookUrl)
165
170
  c.discord.webhookUrl = MASKED;
166
171
  if (c.discord?.clientSecret)
@@ -173,11 +178,12 @@ function maskSecrets(config) {
173
178
  c.slack.clientSecret = MASKED;
174
179
  if (c.teams?.webhookUrl)
175
180
  c.teams.webhookUrl = MASKED;
181
+ delete c.whatsapp;
176
182
  return c;
177
183
  }
178
184
  function mergePreservingSecrets(existing, update) {
179
185
  const merged = { ...defaultConfig(), ...existing };
180
- for (const section of ["desktop", "telegram", "whatsapp", "sms", "email", "ntfy", "discord", "slack", "teams", "dnd", "idle"]) {
186
+ for (const section of ["desktop", "telegram", "sms", "email", "ntfy", "discord", "slack", "teams", "dnd", "idle"]) {
181
187
  merged[section] = { ...(merged[section] || {}), ...(update[section] || {}) };
182
188
  }
183
189
  // Nested schedule inside dnd
@@ -198,7 +204,6 @@ function mergePreservingSecrets(existing, update) {
198
204
  guard(["email", "accessToken"]);
199
205
  guard(["sms", "secretAccessKey"]);
200
206
  guard(["telegram", "token"]);
201
- guard(["whatsapp", "apiToken"]);
202
207
  guard(["discord", "webhookUrl"]);
203
208
  guard(["discord", "clientSecret"]);
204
209
  guard(["slack", "webhookUrl"]);
@@ -605,27 +610,6 @@ app.get("/api/telegram/chats", async (req, res) => {
605
610
  res.status(500).json({ error: String(err) });
606
611
  }
607
612
  });
608
- app.post("/api/test/whatsapp", async (_req, res) => {
609
- const config = loadConfig();
610
- const { instanceId, apiToken, phone } = config.whatsapp ?? {};
611
- if (!instanceId || !apiToken || !phone) {
612
- res.status(400).json({ error: "Instance ID, API token and phone are required." });
613
- return;
614
- }
615
- try {
616
- const r = await fetch(`https://api.green-api.com/waInstance${instanceId}/sendMessage/${apiToken}`, {
617
- method: "POST",
618
- headers: { "Content-Type": "application/json" },
619
- body: JSON.stringify({ chatId: `${phone}@c.us`, message: "Test from Claude Notify — WhatsApp is working!" }),
620
- });
621
- if (!r.ok)
622
- throw new Error(`Green API ${r.status}: ${await r.text()}`);
623
- res.json({ ok: true, message: "WhatsApp message sent!" });
624
- }
625
- catch (err) {
626
- res.status(500).json({ error: String(err) });
627
- }
628
- });
629
613
  app.post("/api/test/sms", async (_req, res) => {
630
614
  const config = loadConfig();
631
615
  const { accessKeyId, secretAccessKey, region, originationNumber, to } = config.sms ?? {};
@@ -1452,19 +1436,30 @@ app.get("/api/logs", (req, res) => {
1452
1436
  logClients.add(res);
1453
1437
  req.on("close", () => logClients.delete(res));
1454
1438
  });
1455
- // ── Notification sender ───────────────────────────────────────────────────────
1456
1439
  async function sendNotification(message, priority, client) {
1457
1440
  const cfg = loadConfig();
1458
1441
  if (cfg.muteAll === true) {
1459
- log("·", "notify", `suppressed — all notifications disabled (master mute), priority=${priority}`, client);
1460
- return "Suppressed — all notifications are disabled (master mute is ON).";
1442
+ log("·", "notify", `BLOCKED — all notifications disabled (master mute), priority=${priority}`, client);
1443
+ return {
1444
+ outcome: "blocked",
1445
+ attempted: [],
1446
+ delivered: [],
1447
+ errors: [],
1448
+ text: "BLOCKED — master mute is ON, so nothing was sent anywhere.",
1449
+ };
1461
1450
  }
1462
1451
  if (client && Array.isArray(cfg.disabledClients) && cfg.disabledClients.includes(client)) {
1463
- log("·", "notify", `suppressed — client '${client}' is disabled, priority=${priority}`, client);
1464
- return `Suppressed — this client ('${client}') is disabled in BullseyeNotify.`;
1452
+ log("·", "notify", `BLOCKED — client '${client}' is disabled, priority=${priority}`, client);
1453
+ return {
1454
+ outcome: "blocked",
1455
+ attempted: [],
1456
+ delivered: [],
1457
+ errors: [],
1458
+ text: `BLOCKED — this client ('${client}') is disabled in BullseyeNotify, so nothing was sent anywhere.`,
1459
+ };
1465
1460
  }
1466
1461
  const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
1467
- const idleSecs = getOsIdleSeconds();
1462
+ const idle = idleReading(getOsIdleSeconds());
1468
1463
  const result = await sendWithRouting({
1469
1464
  message,
1470
1465
  priority,
@@ -1477,7 +1472,7 @@ async function sendNotification(message, priority, client) {
1477
1472
  ctx: {
1478
1473
  inTelegramConversation: inTelegramConvo,
1479
1474
  uiActive: isUiActivelyOpen(),
1480
- idleSeconds: idleSecs,
1475
+ idle,
1481
1476
  },
1482
1477
  enableDesktop: !!cfg.desktop?.enabled,
1483
1478
  enableTelegram: !!(cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatIds?.length),
@@ -1681,8 +1676,14 @@ async function sendNotification(message, priority, client) {
1681
1676
  },
1682
1677
  });
1683
1678
  if (result.suppressedReason) {
1684
- log("·", "notify", `suppressed (${result.suppressedReason})`, client);
1685
- return result.suppressedReason;
1679
+ log("·", "notify", `suppressed (${result.suppressedReason}) — ${result.summary}`, client);
1680
+ return {
1681
+ outcome: "blocked",
1682
+ attempted: [],
1683
+ delivered: [],
1684
+ errors: [],
1685
+ text: result.summary,
1686
+ };
1686
1687
  }
1687
1688
  for (const delivered of result.delivered) {
1688
1689
  log("→", delivered, message, client);
@@ -1690,20 +1691,31 @@ async function sendNotification(message, priority, client) {
1690
1691
  for (const err of result.errors) {
1691
1692
  log("→", "notify", `ERROR: ${err}`, client);
1692
1693
  }
1693
- return [
1694
- result.delivered.length ? `Sent via: ${result.delivered.join(", ")}` : null,
1695
- result.errors.length ? `Errors: ${result.errors.join("; ")}` : null,
1696
- ].filter(Boolean).join(" | ") || "No channels delivered";
1694
+ if (result.outcome === "failed") {
1695
+ log("→", "notify", `NOT DELIVERED — ${result.summary}`, client);
1696
+ }
1697
+ return {
1698
+ outcome: result.outcome === "failed" ? "failed" : "delivered",
1699
+ attempted: result.attempted,
1700
+ delivered: result.delivered,
1701
+ errors: result.errors,
1702
+ text: result.summary,
1703
+ };
1697
1704
  }
1698
- // ── OS idle-time (cross-platform) ─────────────────────────────────────────────
1699
- // Returns seconds since last keyboard/mouse input. -1 on error/unsupported.
1700
- // Clients call the `get_idle_seconds` tool and decide whether to fire a notif.
1705
+ // PowerShell is invoked with -ExecutionPolicy Bypass: the default policy on a
1706
+ // stock Windows box is Restricted, which refuses any .ps1 outright (measured:
1707
+ // `-File` exits 1, `"running scripts is disabled on this system"`), so the
1708
+ // bundled probe could not run at all and every caller saw the -1 sentinel. Only
1709
+ // this one script is exempted — the machine's own policy is untouched.
1701
1710
  const IDLE_SCRIPT_PS1 = join(fileURLToPath(new URL("../../scripts/idle-check.ps1", import.meta.url)));
1711
+ // Returns seconds since last keyboard/mouse input, or -1 when the read did not
1712
+ // happen. Callers MUST convert with `idleReading()` — -1 is a sentinel, never a
1713
+ // measurement, and comparing it against a threshold silently reads as "active".
1702
1714
  function getOsIdleSeconds() {
1703
1715
  try {
1704
1716
  if (process.platform === "win32") {
1705
1717
  // PowerShell + Win32 GetLastInputInfo via bundled script
1706
- const r = spawnSync("powershell", ["-NoProfile", "-File", IDLE_SCRIPT_PS1], {
1718
+ const r = spawnSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", IDLE_SCRIPT_PS1], {
1707
1719
  encoding: "utf-8", windowsHide: true,
1708
1720
  });
1709
1721
  if (r.status === 0) {
@@ -1904,6 +1916,20 @@ function ingestInboxEntry(entry, source) {
1904
1916
  log("·", "inbox", `${source}: ${entry.text} (sse=${sse}, waiters=${waiters.length})`, entry.tag);
1905
1917
  return { waiters: waiters.length, sse };
1906
1918
  }
1919
+ function ingestTelegramEntry(entry) {
1920
+ const { waiters } = ingestInboxEntry(entry, "telegram");
1921
+ const sseCount = sseSubscribersForTag(entry.tag);
1922
+ const listening = sseCount > 0 || waiters > 0;
1923
+ // NULL-OK: field tag is OPTIONAL and therefore it is ok to check for NULL
1924
+ if (entry.tag) {
1925
+ return listening
1926
+ ? `📬 Routed to @${entry.tag}. Waiting for reply.`
1927
+ : `📭 No session @${entry.tag} connected. Message queued — next @${entry.tag} to connect will pick it up.`;
1928
+ }
1929
+ return listening
1930
+ ? `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`
1931
+ : "📭 No agents connected. Message queued — next agent to connect will pick it up.";
1932
+ }
1907
1933
  // Test-only: inject a fake inbox entry exactly as the Telegram listener would.
1908
1934
  // Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
1909
1935
  // production run. Used by the test suite to drive wait_for_inbox wake-up and
@@ -1923,7 +1949,15 @@ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
1923
1949
  app.get("/__test__/slack-clients", (_req, res) => {
1924
1950
  res.json({ tags: slackClientTags(), numbered: slackClientsNumbered() });
1925
1951
  });
1926
- log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox + /__test__/slack-clients enabled");
1952
+ app.post("/__test__/telegram-inbox", express.json(), (req, res) => {
1953
+ if (typeof req.body?.text !== "string") {
1954
+ res.status(400).json({ error: "text required" });
1955
+ return;
1956
+ }
1957
+ const { tag, text } = parseTag(req.body.text);
1958
+ res.json({ ack: ingestTelegramEntry({ text, ts: new Date().toISOString(), tag }) });
1959
+ });
1960
+ log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox + /__test__/slack-clients + /__test__/telegram-inbox enabled");
1927
1961
  }
1928
1962
  app.get("/api/inbox/stream", (req, res) => {
1929
1963
  res.setHeader("Content-Type", "text/event-stream");
@@ -1966,10 +2000,17 @@ app.post("/api/agent/notify", async (req, res) => {
1966
2000
  }
1967
2001
  try {
1968
2002
  const out = await sendNotification(message, priority, "agent-http");
1969
- res.json({ ok: true, result: out });
2003
+ res.status(out.outcome === "delivered" ? 200 : 502).json({
2004
+ ok: out.outcome === "delivered",
2005
+ outcome: out.outcome,
2006
+ attempted: out.attempted,
2007
+ delivered: out.delivered,
2008
+ errors: out.errors,
2009
+ result: out.text,
2010
+ });
1970
2011
  }
1971
2012
  catch (err) {
1972
- res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
2013
+ res.status(500).json({ ok: false, outcome: "failed", error: err instanceof Error ? err.message : String(err) });
1973
2014
  }
1974
2015
  });
1975
2016
  app.get("/api/agent/inbox/poll", (req, res) => {
@@ -2226,39 +2267,7 @@ async function startTelegramListener() {
2226
2267
  const entry = {
2227
2268
  text, ts: new Date().toISOString(), messageId: msg.message_id, tag,
2228
2269
  };
2229
- // Waiters (wait_for_inbox long-poll) get first crack — they were
2230
- // already parked by an agent explicitly asking "wake me up when
2231
- // something arrives." Hand the entry off as a tool *result*, which
2232
- // every MCP client actually surfaces. Only queue if no one was
2233
- // waiting, so the message isn't delivered twice.
2234
- const waiters = takeWaitersFor(tag);
2235
- if (waiters.length > 0) {
2236
- for (const w of waiters) {
2237
- clearTimeout(w.timer);
2238
- w.resolve([entry]);
2239
- }
2240
- log("·", "inbox", `${text} → ${waiters.length} long-poll waiter(s)`, tag);
2241
- }
2242
- else {
2243
- inboxQueue.push(entry);
2244
- }
2245
- writeInboxDrop(entry);
2246
- const liveSseCount = broadcastInbox(entry);
2247
- log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
2248
- const sseCount = sseSubscribersForTag(tag);
2249
- const anyoneListening = sseCount > 0 || waiters.length > 0;
2250
- let ackText;
2251
- if (!anyoneListening && tag) {
2252
- ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
2253
- }
2254
- else if (!anyoneListening) {
2255
- ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
2256
- }
2257
- else {
2258
- ackText = tag
2259
- ? `📬 Routed to @${tag}. Waiting for reply.`
2260
- : `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`;
2261
- }
2270
+ const ackText = ingestTelegramEntry(entry);
2262
2271
  fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
2263
2272
  method: "POST", headers: { "Content-Type": "application/json" },
2264
2273
  body: JSON.stringify({
@@ -2284,7 +2293,6 @@ async function startTelegramListener() {
2284
2293
  }
2285
2294
  }
2286
2295
  // ── Slack inbound poller (always-on; folds slack-poll.sh → #16) ────────────────
2287
- const SLACK_SECRETS_PATH = join(fileURLToPath(new URL("../../notify-secrets.json", import.meta.url)));
2288
2296
  function decodeB64Fields(obj) {
2289
2297
  if (Array.isArray(obj))
2290
2298
  return obj.map(decodeB64Fields);
@@ -2300,17 +2308,36 @@ function decodeB64Fields(obj) {
2300
2308
  }
2301
2309
  return obj;
2302
2310
  }
2311
+ async function sendNotificationSummary(message, priority, client) {
2312
+ const out = await sendNotification(message, priority, client);
2313
+ return out.text;
2314
+ }
2303
2315
  function loadSecrets() {
2304
- if (!existsSync(SLACK_SECRETS_PATH))
2316
+ const path = secretsFilePath();
2317
+ if (!path)
2305
2318
  return {};
2306
2319
  try {
2307
- return decodeB64Fields(JSON.parse(readFileSync(SLACK_SECRETS_PATH, "utf8")));
2320
+ return decodeB64Fields(JSON.parse(readFileSync(path, "utf8")));
2308
2321
  }
2309
2322
  catch (err) {
2310
- log("·", "secrets", `load failed: ${err instanceof Error ? err.message : String(err)}`);
2311
- return {};
2323
+ throw new SecretsUnreadable(path, err);
2324
+ }
2325
+ }
2326
+ // A secrets file that EXISTS but cannot be read is a hard defect, not an empty
2327
+ // result: the caller would otherwise import nothing and believe the user has no
2328
+ // credentials, which is the silent-escalation failure this file exists to stop.
2329
+ class SecretsUnreadable extends Error {
2330
+ constructor(path, cause) {
2331
+ super(`secrets file at ${path} exists but could not be read: ${cause instanceof Error ? cause.message : String(cause)}`);
2312
2332
  }
2313
2333
  }
2334
+ function secretsFilePath() {
2335
+ if (existsSync(SECRETS_PATH))
2336
+ return SECRETS_PATH;
2337
+ if (existsSync(LEGACY_SECRETS_PATH))
2338
+ return LEGACY_SECRETS_PATH;
2339
+ return undefined;
2340
+ }
2314
2341
  function slackCreds() {
2315
2342
  const s = loadSecrets().slack ?? {};
2316
2343
  const cfgSlack = loadConfig().slack ?? {};
@@ -2319,20 +2346,34 @@ function slackCreds() {
2319
2346
  const webhook = (s.webhookUrl ?? cfgSlack.webhookUrl ?? "").trim() || undefined;
2320
2347
  return { token, channel, webhook };
2321
2348
  }
2322
- // Brainless config: on startup, copy any credentials already present in
2323
- // notify-secrets.json (decoded) into config.json's EMPTY fields, so a user who
2324
- // has the shared secrets file gets every channel pre-wired without touching the
2325
- // UI. Never overwrites a value the user already set. Idempotent.
2349
+ // Brainless config: on startup, copy any credentials already present in the
2350
+ // secrets file (decoded) into config.json's EMPTY fields, so a user who has the
2351
+ // shared secrets file gets every channel pre-wired without touching the UI.
2352
+ // Never overwrites a value the user already set. Idempotent. Reports which
2353
+ // channel names were filled — a name list only, never a value.
2354
+ //
2355
+ // OWNER: a secrets file that exists but cannot be read must be REPORTED, not
2356
+ // treated as "no credentials" — silent absence is how every escalation reached
2357
+ // nobody. The recourse is an explicit LOUD line naming the file and the parse
2358
+ // error, so a channel-left-off is diagnosable from the console; the process
2359
+ // still boots, because a broken secrets file must not take the whole server
2360
+ // down. Nothing propagates from this catch.
2326
2361
  function importCredsOnStart() {
2327
2362
  let secrets;
2328
2363
  try {
2329
2364
  secrets = loadSecrets();
2330
2365
  }
2331
- catch {
2366
+ catch (err) {
2367
+ log("·", "secrets", `UNREADABLE — ${err instanceof Error ? err.message : String(err)}. No credentials were imported, so every channel stays as configured.`);
2332
2368
  return;
2333
2369
  }
2334
- if (!secrets || Object.keys(secrets).length === 0)
2370
+ const filled = [];
2371
+ if (Object.keys(secrets).length === 0) {
2372
+ log("·", "import", secretsFilePath()
2373
+ ? "secrets file is present but holds no credentials — nothing to import"
2374
+ : `no secrets file at ${SECRETS_PATH} (set NOTIFY_MCP_SECRETS_PATH to point at one) — nothing to import`);
2335
2375
  return;
2376
+ }
2336
2377
  const cfg = loadConfig();
2337
2378
  let changed = false;
2338
2379
  const fill = (sec, key, val) => {
@@ -2342,6 +2383,7 @@ function importCredsOnStart() {
2342
2383
  if (cfg[sec][key] === undefined || cfg[sec][key] === "") {
2343
2384
  cfg[sec][key] = val;
2344
2385
  changed = true;
2386
+ filled.push(`${sec}.${key}`);
2345
2387
  }
2346
2388
  };
2347
2389
  const fillArrayFromScalar = (sec, arrKey, val) => {
@@ -2351,8 +2393,18 @@ function importCredsOnStart() {
2351
2393
  if (!Array.isArray(cfg[sec][arrKey]) || cfg[sec][arrKey].length === 0) {
2352
2394
  cfg[sec][arrKey] = [String(val)];
2353
2395
  changed = true;
2396
+ filled.push(`${sec}.${arrKey}`);
2354
2397
  }
2355
2398
  };
2399
+ const enableIfPopulated = (sec, required) => {
2400
+ if (cfg[sec]?.enabled === true)
2401
+ return;
2402
+ if (!required.every((k) => { const v = cfg[sec]?.[k]; return Array.isArray(v) ? v.length > 0 : (v !== undefined && v !== null && v !== ""); }))
2403
+ return;
2404
+ cfg[sec].enabled = true;
2405
+ changed = true;
2406
+ filled.push(`${sec}.enabled`);
2407
+ };
2356
2408
  if (secrets.telegram) {
2357
2409
  fill("telegram", "token", secrets.telegram.token);
2358
2410
  fillArrayFromScalar("telegram", "chatIds", secrets.telegram.chatId);
@@ -2372,14 +2424,22 @@ function importCredsOnStart() {
2372
2424
  if (secrets.sms) {
2373
2425
  for (const k of ["accessKeyId", "secretAccessKey", "region", "originationNumber"])
2374
2426
  fill("sms", k, secrets.sms[k]);
2375
- if (secrets.sms.enabled && cfg.sms?.accessKeyId && cfg.sms.enabled !== true) {
2376
- cfg.sms.enabled = true;
2377
- changed = true;
2378
- }
2379
2427
  }
2428
+ // Credentials that reach config.json but leave the channel switched off are
2429
+ // invisible: the routing reads `enabled`, so imported creds alone still send
2430
+ // to nobody. Free channels are switched on when fully populated; a channel
2431
+ // that reaches a real phone (SMS) or a real inbox (email) stays the user's
2432
+ // explicit opt-in, exactly as the shipped config.example.json has it.
2433
+ enableIfPopulated("desktop", []);
2434
+ enableIfPopulated("telegram", ["token", "chatIds"]);
2435
+ enableIfPopulated("slack", ["channels"]);
2436
+ enableIfPopulated("ntfy", ["topic"]);
2380
2437
  if (changed) {
2381
2438
  saveConfig(cfg);
2382
- log("·", "import", "imported credentials from notify-secrets.json into config.json (empty fields only)");
2439
+ log("·", "import", `imported from ${secretsFilePath()} into ${CONFIG_PATH} (empty fields only): ${filled.join(", ")}`);
2440
+ }
2441
+ else {
2442
+ log("·", "import", "secrets file present but every config field it maps to is already set — nothing imported");
2383
2443
  }
2384
2444
  }
2385
2445
  async function slackPost(text) {
@@ -2525,17 +2585,17 @@ async function startSlackListener() {
2525
2585
  }
2526
2586
  app.get("/reply/:token", (req, res) => {
2527
2587
  const pending = pendingAsks.get(req.params.token);
2528
- res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
2529
- <style>body{font-family:sans-serif;background:#0a0a0b;color:#f0f0f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
2530
- .box{background:#111113;border:1px solid #222226;border-radius:9px;padding:24px;max-width:500px;width:90%}
2531
- h2{color:#7c6dfa;margin:0 0 16px}textarea{width:100%;background:#0d0d10;border:1px solid #222226;border-radius:7px;color:#f0f0f0;padding:8px;font-size:14px;resize:vertical;min-height:80px;box-sizing:border-box}
2532
- button{background:#7c6dfa;color:white;border:none;border-radius:7px;padding:8px 20px;font-size:14px;cursor:pointer;margin-top:10px}
2533
- .ok{color:#10b981;margin-top:12px}.err{color:#ef4444}</style></head>
2588
+ res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
2589
+ <style>body{font-family:sans-serif;background:#0a0a0b;color:#f0f0f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
2590
+ .box{background:#111113;border:1px solid #222226;border-radius:9px;padding:24px;max-width:500px;width:90%}
2591
+ h2{color:#7c6dfa;margin:0 0 16px}textarea{width:100%;background:#0d0d10;border:1px solid #222226;border-radius:7px;color:#f0f0f0;padding:8px;font-size:14px;resize:vertical;min-height:80px;box-sizing:border-box}
2592
+ button{background:#7c6dfa;color:white;border:none;border-radius:7px;padding:8px 20px;font-size:14px;cursor:pointer;margin-top:10px}
2593
+ .ok{color:#10b981;margin-top:12px}.err{color:#ef4444}</style></head>
2534
2594
  <body><div class="box">${pending
2535
- ? `<h2>Reply to Claude</h2><textarea id="r" placeholder="Type your response…"></textarea>
2536
- <button onclick="send()">Send</button><div id="s"></div>
2537
- <script>async function send(){const r=document.getElementById('r').value.trim();if(!r)return;
2538
- const res=await fetch('/reply/${req.params.token}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({response:r})});
2595
+ ? `<h2>Reply to Claude</h2><textarea id="r" placeholder="Type your response…"></textarea>
2596
+ <button onclick="send()">Send</button><div id="s"></div>
2597
+ <script>async function send(){const r=document.getElementById('r').value.trim();if(!r)return;
2598
+ const res=await fetch('/reply/${req.params.token}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({response:r})});
2539
2599
  const el=document.getElementById('s');el.textContent=res.ok?'✓ Sent!':'Error';el.className=res.ok?'ok':'err';}</script>`
2540
2600
  : `<h2>Expired</h2><p class="err">This link has already been used or timed out.</p>`}</div></body></html>`);
2541
2601
  });
@@ -2574,171 +2634,190 @@ function appendInbox(baseText, sessionTag, clientId) {
2574
2634
  const inbox = messages.map(m => `[${m.ts}] ${m.text}`).join("\n");
2575
2635
  return `${baseText}\n\n⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${inbox}`;
2576
2636
  }
2577
- const MCP_INSTRUCTIONS = `
2578
- This server delivers notifications to the user through whatever channels the
2579
- user has configured on the server side. The client never needs to know which
2580
- channels are in use — just call 'notify' or 'ask' with a generic message.
2581
-
2582
- BEHAVIORAL RULES for every client that connects:
2583
-
2584
- 1. ALWAYS call 'notify' in these three situations — idle or not, DND or not,
2585
- the server decides routing, you decide whether to fire:
2586
-
2587
- (a) LONG PROCESSING FINISHED. Any single task that took more than ~60
2588
- seconds of wall-clock time (long build, test run, backtest, migration,
2589
- big refactor, multi-step plan) gets a 'notify' the moment it completes
2590
- — success OR failure. Rule of thumb: if the user could have reasonably
2591
- walked away to grab coffee while you ran, they need a ping on the way
2592
- back. Don't try to guess whether they were watching. Just notify.
2593
-
2594
- (b) YOU HAVE A QUESTION OR NEED A DECISION. Any time you're about to ask
2595
- the user something — "should I delete these?", "which branch?",
2596
- "proceed with plan B?" — fire 'notify' (or 'ask' for blocking
2597
- two-way). Silent questions in the terminal get missed; a notification
2598
- does not.
2599
-
2600
- (c) SOMETHING IMPORTANT HAPPENED that the user needs to know about right
2601
- now. Examples: a test suddenly failed after being green, a destructive
2602
- operation is about to run, you found a security issue, a deploy
2603
- succeeded, a production service looks degraded, you hit an
2604
- unrecoverable error. When in doubt on importance, ERR ON THE SIDE OF
2605
- NOTIFYING — the server's idle gating will automatically downgrade a
2606
- mis-judged 'normal' to a silent desktop banner if the user is active,
2607
- so the cost of over-notifying is near zero. The cost of missing a
2608
- real event is that the user finds out 4 hours later.
2609
-
2610
- The SERVER handles all routing (DND, idle threshold, channel selection,
2611
- priority escalation). You do NOT need to pre-flight with
2612
- 'get_idle_seconds' before these three triggers — fire 'notify' and let
2613
- the server decide. get_idle_seconds is the HEARTBEAT primitive (rule 6),
2614
- not a gate on legitimate milestones.
2615
-
2616
- 2. Use priority correctly:
2617
- - 'low' = email only — for low-stakes status (background completion).
2618
- - 'normal' = desktop + Telegram + email — the default.
2619
- - 'high' = all channels including SMS — bypasses DND AND idle gating.
2620
- Use ONLY for catastrophic findings or decisions that block
2621
- progress. Misuse will train the user to ignore your notifs.
2622
-
2623
- 3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
2624
- output as well as sending it through 'notify'. The user may be reading the
2625
- terminal directly; don't rely on them checking their phone / email. Do NOT
2626
- shorten, summarise, or cut off the message with "…" in your chat output —
2627
- show every word exactly as sent.
2628
-
2629
- 4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
2630
- 'email', 'desktop', etc. in your messages or in your chat output — those are
2631
- server delivery details the user has already configured and the client has
2632
- no business surfacing. Do NOT echo "Sent via: <channel list>" or any
2633
- variant of it. Just say 'notif' or 'notification' if you need to refer to
2634
- the act of notifying.
2635
-
2636
- 5. When the user sends you an unsolicited message (visible as INBOX items in
2637
- the 'notify' response, via 'poll', via 'wait_for_inbox', via
2638
- 'get_idle_seconds' piggy-back, or via the /api/inbox/stream SSE), reply to
2639
- them THROUGH 'notify' so the reply
2640
- actually reaches them — not just in your chat output. Multiple agents may
2641
- be connected simultaneously — the server broadcasts every untagged inbox
2642
- message to all of them, so the user can see who is listening. Your reply
2643
- MUST identify which session you are (start with your own tag or client id,
2644
- e.g. "[@alphawave]" or "[sess-abcd]") and give a brief status so the user
2645
- can pick whom to respond to. If the user tagged their message (@<tag>),
2646
- only the session with that tag should reply. If you are untagged and
2647
- another session with the same project/workdir is already tagged, let the
2648
- tagged one reply.
2649
-
2650
- 5a. BUSY-ACK RULE (hard requirement): The moment you notice a user inbox
2651
- message — whether via notify response, heartbeat piggy-back, or poll —
2652
- REPLY IMMEDIATELY via 'notify' with a terse status, EVEN IF YOU CAN'T
2653
- ACT ON THE REQUEST YET. Do not finish the current tool/task first.
2654
- Drop what you're doing long enough to fire one 'notify' call, THEN
2655
- resume. Format: "[<your-session-id>] busy on <current-task>; will
2656
- respond at <milestone>". The user needs to know (a) you heard them,
2657
- (b) you're not the stuck one, (c) when to expect a real answer. An
2658
- inbox message that gets a delayed reply is worse than no reply — it
2659
- wastes the user's time waiting on silence.
2660
-
2661
- 6. The 'get_idle_seconds', 'get_idle_config', and 'get_dnd_status' tools are
2662
- informational reads, but they ALSO drain pending inbox messages. Use them
2663
- as a cheap heartbeat during long work: call 'get_idle_seconds' EVERY 15-
2664
- 30 SECONDS while a long task runs (loop iteration, backtest, scan, build,
2665
- etc.). If the user sent you a message while you were busy, it comes back
2666
- piggy-backed on the response — you don't need to separately call 'poll'.
2667
- This is a lightweight local read (no network, no DND or channel routing),
2668
- so the cost is near zero. Without this heartbeat pattern, a busy agent is
2669
- deaf to the user until its next 'notify' call — which may be minutes or
2670
- hours away during long work. Treat 'get_idle_seconds' as the "check for
2671
- user input" primitive, not an idle-gate check.
2672
-
2673
- If your work is naturally idle (waiting for the user, between loop iters),
2674
- prefer 'wait_for_inbox' instead — it blocks up to 50s and returns the
2675
- moment the user types anything, as a tool result. That's the most reliable
2676
- delivery path across every MCP client (notifications over SSE are silently
2677
- dropped by Claude Code, Cursor, and others). Loop pattern:
2678
- while (true) {
2679
- const r = await wait_for_inbox({ timeout_seconds: 50 });
2680
- if (r !== "inbox:empty") handle(r);
2681
- }
2682
-
2683
- 7. If your tool call fails with "MCP server not connected" / "transport
2684
- closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
2685
- connected to the same server right now. Only YOUR client's transport
2686
- broke (commonly: stdio child process died, or HTTP client gave up after a
2687
- transient slow response at startup). DO NOT tell the user "notify is down"
2688
- without first probing the server directly.
2689
-
2690
- Probe before declaring the server dead:
2691
- curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3737/mcp
2692
- - 406 (Not Acceptable) = server is healthy, doing MCP protocol
2693
- negotiation. Your client is the broken party. Use the bypass below.
2694
- - Connection refused / timeout = server is genuinely down. Tell the user.
2695
-
2696
- HTTP-transport bypass when your MCP client is broken (works from any
2697
- shell that has curl). Three calls, stateful — the session id from step 1
2698
- must be passed in mcp-session-id on every subsequent call:
2699
-
2700
- # 1) initialize and capture session id from response headers
2701
- SID=$(curl -s -i -X POST http://localhost:3737/mcp \
2702
- -H "Content-Type: application/json" \
2703
- -H "Accept: application/json, text/event-stream" \
2704
- -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-bridge","version":"1.0"}}}' \
2705
- | grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
2706
-
2707
- # 2) ack initialized (no response body, fire-and-forget)
2708
- curl -s -X POST http://localhost:3737/mcp \
2709
- -H "Content-Type: application/json" \
2710
- -H "Accept: application/json, text/event-stream" \
2711
- -H "mcp-session-id: $SID" \
2712
- -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
2713
-
2714
- # 3) call any tool — successful response includes "Sent via: <channel>"
2715
- curl -s -X POST http://localhost:3737/mcp \
2716
- -H "Content-Type: application/json" \
2717
- -H "Accept: application/json, text/event-stream" \
2718
- -H "mcp-session-id: $SID" \
2719
- -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notify","arguments":{"message":"YOUR MESSAGE","priority":"normal"}}}'
2720
-
2721
- Same pattern works for 'ask', 'poll', 'get_idle_seconds', etc. — change
2722
- the "name" and "arguments" in step 3. Reuse the same $SID for all calls
2723
- in one bypass session.
2724
-
2725
- Bottom line: rule #1 still applies. If you owe the user a notify and your
2726
- MCP client is broken, you owe them the bypass — not an excuse.
2637
+ const MCP_INSTRUCTIONS = `
2638
+ This server is how you reach the user — and only you can decide to use it:
2639
+ nothing here delivers a message the user never asked for unless you or your
2640
+ own loop calls a tool. The channels are configured on the server side, so
2641
+ just call 'notify' or 'ask' with a generic message and let the server route
2642
+ it.
2643
+
2644
+ BEHAVIORAL RULES for every client that connects:
2645
+
2646
+ 1. ALWAYS call 'notify' in these three situations — idle or not, DND or not,
2647
+ the server decides routing, you decide whether to fire:
2648
+
2649
+ (a) LONG PROCESSING FINISHED. Any single task that took more than ~60
2650
+ seconds of wall-clock time (long build, test run, backtest, migration,
2651
+ big refactor, multi-step plan) gets a 'notify' the moment it completes
2652
+ — success OR failure. Rule of thumb: if the user could have reasonably
2653
+ walked away to grab coffee while you ran, they need a ping on the way
2654
+ back. Don't try to guess whether they were watching. Just notify.
2655
+
2656
+ (b) YOU HAVE A QUESTION OR NEED A DECISION. Any time you're about to ask
2657
+ the user something — "should I delete these?", "which branch?",
2658
+ "proceed with plan B?" — fire 'notify' (or 'ask' for blocking
2659
+ two-way). Silent questions in the terminal get missed; a notification
2660
+ does not.
2661
+
2662
+ (c) SOMETHING IMPORTANT HAPPENED that the user needs to know about right
2663
+ now. Examples: a test suddenly failed after being green, a destructive
2664
+ operation is about to run, you found a security issue, a deploy
2665
+ succeeded, a production service looks degraded, you hit an
2666
+ unrecoverable error. When in doubt on importance, ERR ON THE SIDE OF
2667
+ NOTIFYING — the server's idle gating automatically holds a mis-judged
2668
+ 'normal' back to local delivery while the user is active, so the
2669
+ cost of over-notifying is near zero. The cost of missing a
2670
+ real event is that the user finds out 4 hours later.
2671
+
2672
+ The SERVER handles all routing (DND, idle threshold, channel selection,
2673
+ priority escalation). You do NOT need to pre-flight with
2674
+ 'get_idle_seconds' before these three triggers — fire 'notify' and let
2675
+ the server decide. get_idle_seconds is the HEARTBEAT primitive (rule 6),
2676
+ not a gate on legitimate milestones.
2677
+
2678
+ 2. Use priority correctly. Channels each priority reaches, when enabled:
2679
+ ${describePriorityRouting()}
2680
+ - 'low' = low-stakes status (background completion).
2681
+ - 'normal' = the default.
2682
+ - 'high' = bypasses DND AND idle gating.
2683
+ Use ONLY for catastrophic findings or decisions that block
2684
+ progress. Misuse will train the user to ignore your notifs.
2685
+
2686
+ 3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
2687
+ output as well as sending it through 'notify'. The user may be reading the
2688
+ terminal directly; don't rely on them checking their phone / email. Do NOT
2689
+ shorten, summarise, or cut off the message with "…" in your chat output —
2690
+ show every word exactly as sent.
2691
+
2692
+ 3a. NEVER TRUNCATE — 500 CHARACTERS IS THE LIMIT ON ONE 'notify' MESSAGE.
2693
+ A longer message goes as numbered parts — '(1/N) ...', '(2/N) ...' — in
2694
+ separate 'notify' calls, in order, each at or under 500 characters. A
2695
+ single call over the cap is rejected as 'too_big': that means split it,
2696
+ never shorten it. Every fact, file path, line number and recommendation
2697
+ the user needs must be in the parts you send — a shortened notification
2698
+ is a defect, not a saving.
2699
+ (The Claude Code bridge is the one exception: it accepts up to 5000
2700
+ characters in a single call and produces those numbered parts itself, so
2701
+ there you send the whole message and never hand-split.)
2702
+
2703
+ 4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
2704
+ 'email', 'desktop', etc. in your messages or in your chat output — those are
2705
+ server delivery details the user has already configured and the client has
2706
+ no business surfacing. Do NOT echo "Sent via: <channel list>" or any
2707
+ variant of it. Just say 'notif' or 'notification' if you need to refer to
2708
+ the act of notifying.
2709
+
2710
+ 5. When the user sends you an unsolicited message (visible as INBOX items in
2711
+ the 'notify' response, via 'poll', via 'wait_for_inbox', via
2712
+ 'get_idle_seconds' piggy-back, or via the /api/inbox/stream SSE), reply to
2713
+ them THROUGH 'notify' so the reply
2714
+ actually reaches them — not just in your chat output. Multiple agents may
2715
+ be connected simultaneously — the server broadcasts every untagged inbox
2716
+ message to all of them, so the user can see who is listening. Your reply
2717
+ MUST identify which session you are (start with your own tag or client id,
2718
+ e.g. "[@alphawave]" or "[sess-abcd]") and give a brief status so the user
2719
+ can pick whom to respond to. If the user tagged their message (@<tag>),
2720
+ only the session with that tag should reply. If you are untagged and
2721
+ another session with the same project/workdir is already tagged, let the
2722
+ tagged one reply.
2723
+
2724
+ 5a. BUSY-ACK RULE (hard requirement): The moment you notice a user inbox
2725
+ message — whether via notify response, heartbeat piggy-back, or poll —
2726
+ REPLY IMMEDIATELY via 'notify' with a terse status, EVEN IF YOU CAN'T
2727
+ ACT ON THE REQUEST YET. Do not finish the current tool/task first.
2728
+ Drop what you're doing long enough to fire one 'notify' call, THEN
2729
+ resume. Format: "[<your-session-id>] busy on <current-task>; will
2730
+ respond at <milestone>". The user needs to know (a) you heard them,
2731
+ (b) you're not the stuck one, (c) when to expect a real answer. An
2732
+ inbox message that gets a delayed reply is worse than no reply — it
2733
+ wastes the user's time waiting on silence.
2734
+
2735
+ 6. The 'get_idle_seconds', 'get_idle_config', and 'get_dnd_status' tools are
2736
+ informational reads, but they ALSO drain pending inbox messages. Use them
2737
+ as a cheap heartbeat during long work: call 'get_idle_seconds' EVERY 15-
2738
+ 30 SECONDS while a long task runs (loop iteration, backtest, scan, build,
2739
+ etc.). If the user sent you a message while you were busy, it comes back
2740
+ piggy-backed on the response — you don't need to separately call 'poll'.
2741
+ This is a lightweight local read (no network, no DND or channel routing),
2742
+ so the cost is near zero. Without this heartbeat pattern, a busy agent is
2743
+ deaf to the user until its next 'notify' call — which may be minutes or
2744
+ hours away during long work. Treat 'get_idle_seconds' as the "check for
2745
+ user input" primitive, not an idle-gate check.
2746
+
2747
+ If your work is naturally idle (waiting for the user, between loop iters),
2748
+ prefer 'wait_for_inbox' instead — it blocks up to 50s and returns the
2749
+ moment the user types anything, as a tool result. That's the most reliable
2750
+ delivery path across every MCP client (notifications over SSE are silently
2751
+ dropped by Claude Code, Cursor, and others). Loop pattern:
2752
+ while (true) {
2753
+ const r = await wait_for_inbox({ timeout_seconds: 50 });
2754
+ if (r !== "inbox:empty") handle(r);
2755
+ }
2756
+
2757
+ 7. If your tool call fails with "MCP server not connected" / "transport
2758
+ closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
2759
+ connected to the same server right now. Only YOUR client's transport
2760
+ broke (commonly: stdio child process died, or HTTP client gave up after a
2761
+ transient slow response at startup). DO NOT tell the user "notify is down"
2762
+ without first probing the server directly.
2763
+
2764
+ Probe before declaring the server dead:
2765
+ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3737/mcp
2766
+ - 406 (Not Acceptable) = server is healthy, doing MCP protocol
2767
+ negotiation. Your client is the broken party. Use the bypass below.
2768
+ - Connection refused / timeout = server is genuinely down. Tell the user.
2769
+
2770
+ HTTP-transport bypass when your MCP client is broken (works from any
2771
+ shell that has curl). Three calls, stateful — the session id from step 1
2772
+ must be passed in mcp-session-id on every subsequent call:
2773
+
2774
+ # 1) initialize and capture session id from response headers
2775
+ SID=$(curl -s -i -X POST http://localhost:3737/mcp \
2776
+ -H "Content-Type: application/json" \
2777
+ -H "Accept: application/json, text/event-stream" \
2778
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-bridge","version":"1.0"}}}' \
2779
+ | grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
2780
+
2781
+ # 2) ack initialized (no response body, fire-and-forget)
2782
+ curl -s -X POST http://localhost:3737/mcp \
2783
+ -H "Content-Type: application/json" \
2784
+ -H "Accept: application/json, text/event-stream" \
2785
+ -H "mcp-session-id: $SID" \
2786
+ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
2787
+
2788
+ # 3) call any tool — a delivered notify answers with outcome=delivered
2789
+ curl -s -X POST http://localhost:3737/mcp \
2790
+ -H "Content-Type: application/json" \
2791
+ -H "Accept: application/json, text/event-stream" \
2792
+ -H "mcp-session-id: $SID" \
2793
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notify","arguments":{"message":"YOUR MESSAGE","priority":"normal"}}}'
2794
+
2795
+ Same pattern works for 'ask', 'poll', 'get_idle_seconds', etc. — change
2796
+ the "name" and "arguments" in step 3. Reuse the same $SID for all calls
2797
+ in one bypass session.
2798
+
2799
+ Bottom line: rule #1 still applies. If you owe the user a notify and your
2800
+ MCP client is broken, you owe them the bypass — not an excuse.
2727
2801
  `.trim();
2728
2802
  function createMcpServer(clientId, sessionTag) {
2729
2803
  const identity = `@${clientId}`;
2730
2804
  const identityLine = `\nYOUR SESSION IDENTITY: "${identity}" — use this as your prefix in all notify replies (e.g. "[${identity}] done with build").\n`;
2731
2805
  const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: identityLine + MCP_INSTRUCTIONS });
2732
2806
  server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
2733
- "Before calling, check get_idle_seconds against get_idle_config.thresholdSeconds; " +
2734
- "skip the call if the user is active (unless priority='high'). " +
2735
- "Use for: task milestones, questions needing input, catastrophic findings, long task completion.", {
2807
+ "The server applies DND and user-activity gating itself, so call it whenever a trigger fires — never pre-check idle. " +
2808
+ "Use for: task milestones, questions needing input, catastrophic findings, long task completion. " +
2809
+ "One message is limited to 500 characters — send anything longer as numbered parts, " +
2810
+ "'(1/N) ...', '(2/N) ...', as separate calls in order, each at or under 500 characters. " +
2811
+ "Never shorten the message to fit; 'too_big' means split it.", {
2736
2812
  message: z.string().max(500).describe("Notification message, max 500 chars"),
2737
2813
  priority: z.enum(["low", "normal", "high"]).default("normal")
2738
- .describe("low=email only; normal=desktop+telegram+email; high=all channels"),
2814
+ .describe(`Channels per priority, each only when enabled: ${describePriorityRouting()} — high also bypasses DND and activity gating`),
2739
2815
  }, async ({ message, priority }) => {
2740
2816
  const outbound = `[${clientId}] ${message}`;
2741
- const summary = await sendNotification(outbound, priority, clientId);
2817
+ const out = await sendNotification(outbound, priority, clientId);
2818
+ const prefix = out.outcome === "delivered" ? "" : `⚠️ NOT DELIVERED — this notification reached nobody.\n${out.text}`;
2819
+ const summary = prefix
2820
+ + `${prefix ? "\n\n" : ""}outcome=${out.outcome} attempted=[${out.attempted.join(", ")}] delivered=[${out.delivered.join(", ")}]`;
2742
2821
  const messages = drainInboxFor(sessionTag);
2743
2822
  if (messages.length === 0) {
2744
2823
  return { content: [{ type: "text", text: summary }] };
@@ -2796,7 +2875,7 @@ function createMcpServer(clientId, sessionTag) {
2796
2875
  from: email.connectedEmail ?? email.user ?? email.to,
2797
2876
  to: email.to,
2798
2877
  subject: `Claude asks: ${question.slice(0, 60)}`,
2799
- html: `<p style="font-size:16px">${question}</p>
2878
+ html: `<p style="font-size:16px">${question}</p>
2800
2879
  <p><a href="${replyUrl}" style="background:#7c6dfa;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;display:inline-block;margin-top:8px">Reply to Claude</a></p>`,
2801
2880
  });
2802
2881
  log("→", "ask:email", `question sent to ${email.to}, reply URL: ${replyUrl}`, clientId);
@@ -2863,14 +2942,18 @@ function createMcpServer(clientId, sessionTag) {
2863
2942
  const body = entries.map(m => `[${m.ts}] ${m.text}`).join("\n");
2864
2943
  return { content: [{ type: "text", text: `⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${body}` }] };
2865
2944
  });
2866
- server.tool("get_idle_seconds", "Returns the number of seconds since the user's last keyboard/mouse input. " +
2945
+ server.tool("get_idle_seconds", "Returns how long it has been since the user's last keyboard/mouse input. " +
2867
2946
  "Call this periodically during long work as a cheap heartbeat — the server " +
2868
2947
  "will piggy-back any pending inbox messages in the response, so you stay " +
2869
- "responsive to the user without having to call poll. Returns -1 if idle " +
2870
- "detection is unsupported on this platform — in that case, proceed without " +
2871
- "gating (fail-open).", {}, async () => {
2872
- const secs = getOsIdleSeconds();
2873
- return { content: [{ type: "text", text: appendInbox(String(secs), sessionTag, clientId) }] };
2948
+ "responsive to the user without having to call poll. The answer is either " +
2949
+ "'idle: measured N seconds since the last keyboard/mouse input' or " +
2950
+ "'idle: could not measure — user activity unknown, so activity gating is " +
2951
+ "skipped'. Those are two DIFFERENT states: the first is a reading, the " +
2952
+ "second means this machine cannot report user activity at all and the " +
2953
+ "server therefore delivers instead of gating. Neither is a number to " +
2954
+ "compare against a threshold yourself — the server gates internally.", {}, async () => {
2955
+ const reading = idleReading(getOsIdleSeconds());
2956
+ return { content: [{ type: "text", text: appendInbox(formatIdleReading(reading), sessionTag, clientId) }] };
2874
2957
  });
2875
2958
  server.tool("get_idle_config", "Returns the server's idle gating policy: { enabled, thresholdSeconds, alwaysDesktopWhenActive }. " +
2876
2959
  "Informational only — the server gates internally on every notify call. " +
@@ -3183,14 +3266,28 @@ function trackReconnect(clientId) {
3183
3266
  const count = reconnectedClients.length;
3184
3267
  const msg = `${count} client${count === 1 ? "" : "s"} reconnected and received updated instructions: ${list}`;
3185
3268
  try {
3186
- await sendNotification(msg, "low", "omni-notify-mcp");
3269
+ await sendNotificationSummary(msg, "low", "omni-notify-mcp");
3187
3270
  }
3188
3271
  catch { /* best effort */ }
3189
3272
  }, RECONNECT_WINDOW_MS);
3190
3273
  }
3191
3274
  }
3192
3275
  // ── Start ─────────────────────────────────────────────────────────────────────
3193
- const httpServer = app.listen(PORT, "0.0.0.0", () => {
3276
+ // The Telegram poller needs `getUpdates` over the outbound network and stays
3277
+ // off under NOTIFY_MCP_TEST_ENDPOINTS. The other two have no such dependency:
3278
+ // importCredsOnStart only reads a local file, and the Slack listener is inert
3279
+ // until Slack credentials exist, so a fresh install with populated secrets can
3280
+ // still import them from a TEST_ENDPOINTS process.
3281
+ const TEST_ENDPOINTS = process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1";
3282
+ // Bound to EVERY interface, IPv4 and IPv6.
3283
+ //
3284
+ // This was `app.listen(PORT, "0.0.0.0", …)`, which is IPv4-only. On Windows a client that resolves
3285
+ // `localhost` to `::1` first then reaches a socket that is not listening — `[::1]:3737` answers nothing
3286
+ // while `127.0.0.1:3737` is fine — so the MCP endpoint was unreachable for any client whose resolver
3287
+ // prefers IPv6, and VS Code reported "unable to start" for a server that was running correctly. Binding
3288
+ // the hostname instead of the IPv4 wildcard opens both families, so `localhost`, `127.0.0.1` and `::1`
3289
+ // all work and no client has to know which one this process happens to speak.
3290
+ const httpServer = app.listen(PORT, () => {
3194
3291
  const ip = getLocalIp();
3195
3292
  console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
3196
3293
  if (ENABLE_MCP) {
@@ -3199,9 +3296,12 @@ const httpServer = app.listen(PORT, "0.0.0.0", () => {
3199
3296
  else {
3200
3297
  console.log(" MCP endpoint (remote) → disabled (set ENABLE_MCP=1 to enable)\n");
3201
3298
  }
3202
- // Live Slack/Telegram pollers hit real external channels — never under test.
3203
- if (process.env.NOTIFY_MCP_TEST_ENDPOINTS !== "1") {
3204
- importCredsOnStart();
3299
+ // Import runs on EVERY boot path, before the listeners start, so an empty
3300
+ // config.json cannot serve a process whose secrets file IS populated. It
3301
+ // reports the channel names it filled — never a secret value.
3302
+ importCredsOnStart();
3303
+ // Live Telegram/Slack pollers hit real external channels — never under test.
3304
+ if (!TEST_ENDPOINTS) {
3205
3305
  startTelegramListener();
3206
3306
  startSlackListener();
3207
3307
  }