omni-notify-mcp 1.2.0 → 1.2.3

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.
@@ -36,7 +36,7 @@ export async function speak(text, voice = DEFAULT_TTS_VOICE) {
36
36
  if (process.platform === "win32") {
37
37
  spawn("powershell", [
38
38
  "-NoProfile", "-Command",
39
- `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -Seconds 10`,
39
+ `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
40
40
  ], { windowsHide: true, stdio: "ignore" });
41
41
  }
42
42
  else if (process.platform === "darwin") {
package/dist/index.js CHANGED
@@ -171,7 +171,17 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
171
171
  instructions: "This is the stdio bridge for notify-mcp. It pushes unsolicited user " +
172
172
  "messages to the agent via `notifications/claude/channel` when the host " +
173
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.",
174
+ "as a long-poll to reliably receive user messages as tool results.\n\n" +
175
+ "ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
176
+ "just finished (success or failure), (b) you have a question or need a " +
177
+ "decision, (c) something important happened the user needs to know right " +
178
+ "now. Idle/DND gating is handled server-side — fire notify and let the " +
179
+ "server decide routing. Err on the side of notifying: a wrong-call gets " +
180
+ "silently downgraded by idle gating; a missed notify costs the user hours.\n\n" +
181
+ "ALWAYS echo the COMPLETE, UNTRUNCATED message in your chat output — never " +
182
+ "shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
183
+ "SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
184
+ "Say 'notif' or 'notification' if you need to refer to the act of notifying.",
175
185
  });
176
186
  // Thin proxy: forward a tool call to the HTTP server and return its content
177
187
  // block array verbatim. Error shape matches what the SDK expects from tool
package/dist/ui/server.js CHANGED
@@ -181,7 +181,7 @@ async function speakText(text, voice) {
181
181
  if (process.platform === "win32") {
182
182
  spawn("powershell", [
183
183
  "-NoProfile", "-Command",
184
- `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -Seconds 10`,
184
+ `Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
185
185
  ], { windowsHide: true, stdio: "ignore" });
186
186
  }
187
187
  else if (process.platform === "darwin") {
@@ -558,11 +558,29 @@ app.get("/api/sessions", (_req, res) => {
558
558
  tag: s.tag,
559
559
  clientName: s.clientName,
560
560
  clientVersion: s.clientVersion,
561
+ workspaceName: s.workspaceName,
561
562
  host: s.host,
562
563
  connectedAt: s.connectedAt,
564
+ lastSeen: s.lastSeen,
563
565
  }));
564
566
  res.json({ sessions: list });
565
567
  });
568
+ app.delete("/api/sessions/:clientId", (req, res) => {
569
+ const { clientId } = req.params;
570
+ const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
571
+ if (!entry) {
572
+ res.status(404).json({ error: "not found" });
573
+ return;
574
+ }
575
+ const [sessionId] = entry;
576
+ try {
577
+ httpTransports[sessionId]?.close();
578
+ }
579
+ catch { /* ignore */ }
580
+ delete httpTransports[sessionId];
581
+ delete sessions[sessionId];
582
+ res.json({ ok: true });
583
+ });
566
584
  app.get("/api/logs", (req, res) => {
567
585
  res.setHeader("Content-Type", "text/event-stream");
568
586
  res.setHeader("Cache-Control", "no-cache");
@@ -1116,12 +1134,37 @@ channels are in use — just call 'notify' or 'ask' with a generic message.
1116
1134
 
1117
1135
  BEHAVIORAL RULES for every client that connects:
1118
1136
 
1119
- 1. Always call 'notify' for milestones, decisions, completions. The SERVER
1120
- handles all gating (DND, idle, channel routing). You do not need to
1121
- pre-flight with 'get_idle_seconds' — the server checks it itself and
1122
- downgrades the delivery automatically. When the user is active, the server
1123
- will play a local desktop sound+banner so they know something happened
1124
- (without blasting their phone). Just call 'notify'.
1137
+ 1. ALWAYS call 'notify' in these three situations — idle or not, DND or not,
1138
+ the server decides routing, you decide whether to fire:
1139
+
1140
+ (a) LONG PROCESSING FINISHED. Any single task that took more than ~60
1141
+ seconds of wall-clock time (long build, test run, backtest, migration,
1142
+ big refactor, multi-step plan) gets a 'notify' the moment it completes
1143
+ — success OR failure. Rule of thumb: if the user could have reasonably
1144
+ walked away to grab coffee while you ran, they need a ping on the way
1145
+ back. Don't try to guess whether they were watching. Just notify.
1146
+
1147
+ (b) YOU HAVE A QUESTION OR NEED A DECISION. Any time you're about to ask
1148
+ the user something — "should I delete these?", "which branch?",
1149
+ "proceed with plan B?" — fire 'notify' (or 'ask' for blocking
1150
+ two-way). Silent questions in the terminal get missed; a notification
1151
+ does not.
1152
+
1153
+ (c) SOMETHING IMPORTANT HAPPENED that the user needs to know about right
1154
+ now. Examples: a test suddenly failed after being green, a destructive
1155
+ operation is about to run, you found a security issue, a deploy
1156
+ succeeded, a production service looks degraded, you hit an
1157
+ unrecoverable error. When in doubt on importance, ERR ON THE SIDE OF
1158
+ NOTIFYING — the server's idle gating will automatically downgrade a
1159
+ mis-judged 'normal' to a silent desktop banner if the user is active,
1160
+ so the cost of over-notifying is near zero. The cost of missing a
1161
+ real event is that the user finds out 4 hours later.
1162
+
1163
+ The SERVER handles all routing (DND, idle threshold, channel selection,
1164
+ priority escalation). You do NOT need to pre-flight with
1165
+ 'get_idle_seconds' before these three triggers — fire 'notify' and let
1166
+ the server decide. get_idle_seconds is the HEARTBEAT primitive (rule 6),
1167
+ not a gate on legitimate milestones.
1125
1168
 
1126
1169
  2. Use priority correctly:
1127
1170
  - 'low' = email only — for low-stakes status (background completion).
@@ -1130,13 +1173,18 @@ BEHAVIORAL RULES for every client that connects:
1130
1173
  Use ONLY for catastrophic findings or decisions that block
1131
1174
  progress. Misuse will train the user to ignore your notifs.
1132
1175
 
1133
- 3. Echo the full message body in your own chat / conversation output as well
1134
- as sending it through 'notify'. The user may be reading the terminal
1135
- directly; don't rely on them checking their phone / email.
1176
+ 3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
1177
+ output as well as sending it through 'notify'. The user may be reading the
1178
+ terminal directly; don't rely on them checking their phone / email. Do NOT
1179
+ shorten, summarise, or cut off the message with "…" in your chat output —
1180
+ show every word exactly as sent.
1136
1181
 
1137
1182
  4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
1138
- 'email', etc. in your messages those are server delivery details the
1139
- user has already configured. Say 'notif' or 'notification' instead.
1183
+ 'email', 'desktop', etc. in your messages or in your chat output those are
1184
+ server delivery details the user has already configured and the client has
1185
+ no business surfacing. Do NOT echo "Sent via: <channel list>" or any
1186
+ variant of it. Just say 'notif' or 'notification' if you need to refer to
1187
+ the act of notifying.
1140
1188
 
1141
1189
  5. When the user sends you an unsolicited message (visible as INBOX items in
1142
1190
  the 'notify' response, via 'poll', via 'wait_for_inbox', via
@@ -1231,7 +1279,9 @@ BEHAVIORAL RULES for every client that connects:
1231
1279
  MCP client is broken, you owe them the bypass — not an excuse.
1232
1280
  `.trim();
1233
1281
  function createMcpServer(clientId, sessionTag) {
1234
- const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: MCP_INSTRUCTIONS });
1282
+ const identity = sessionTag ? `@${sessionTag}` : clientId;
1283
+ const identityLine = `\nYOUR SESSION IDENTITY: "${identity}" — use this as your prefix in all notify replies (e.g. "[${identity}] done with build").\n`;
1284
+ const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: identityLine + MCP_INSTRUCTIONS });
1235
1285
  server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
1236
1286
  "Before calling, check get_idle_seconds against get_idle_config.thresholdSeconds; " +
1237
1287
  "skip the call if the user is active (unless priority='high'). " +
@@ -1475,14 +1525,16 @@ function sessionDisplay(s) {
1475
1525
  return s.tag ? `@${s.tag}` : s.clientId;
1476
1526
  }
1477
1527
  app.all("/mcp", async (req, res) => {
1528
+ console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
1478
1529
  const existingSessionId = req.headers["mcp-session-id"];
1479
1530
  if (existingSessionId && httpTransports[existingSessionId]) {
1480
- await httpTransports[existingSessionId].handleRequest(req, res, req.body);
1531
+ const transport = httpTransports[existingSessionId];
1532
+ await transport.handleRequest(req, res, req.body);
1481
1533
  // Lazy-populate clientInfo after initialize lands on an existing session.
1482
1534
  const meta = sessions[existingSessionId];
1483
1535
  if (meta)
1484
1536
  meta.lastSeen = Date.now();
1485
- const mcpServer = httpTransports[existingSessionId].__mcpServer;
1537
+ const mcpServer = httpTransports[existingSessionId]?.__mcpServer;
1486
1538
  if (meta && !meta.clientName && mcpServer?.getClientVersion) {
1487
1539
  try {
1488
1540
  const info = mcpServer.getClientVersion();
@@ -1495,12 +1547,19 @@ app.all("/mcp", async (req, res) => {
1495
1547
  }
1496
1548
  return;
1497
1549
  }
1498
- // Spec-compliant: if the client presents a session ID we don't know about
1499
- // (server was restarted, or the transport was closed), return 404 so the
1500
- // client knows to re-initialize. The previous silent-new-session behavior
1501
- // left idle clients stuck with stale session bookkeeping until the VS Code
1502
- // window was manually reloaded.
1503
- if (existingSessionId) {
1550
+ // Auto-reconnect path. If the client presents a session id we don't know
1551
+ // about AND the request body is a fresh `initialize`, adopt the stale id
1552
+ // instead of 404-ing. This covers the "server was restarted while Claude
1553
+ // Code was open" case: clients that cache the session id (claude-code#27142)
1554
+ // would otherwise stay ghost until the human manually reloaded the window.
1555
+ // A non-initialize request with an unknown id still gets 404 — the client
1556
+ // is expected to reinitialize in response.
1557
+ const bodyIsInitialize = req.method === "POST" &&
1558
+ req.body &&
1559
+ (Array.isArray(req.body)
1560
+ ? req.body.some((m) => m?.method === "initialize")
1561
+ : req.body.method === "initialize");
1562
+ if (existingSessionId && !bodyIsInitialize) {
1504
1563
  res.status(404).json({
1505
1564
  jsonrpc: "2.0",
1506
1565
  error: { code: -32000, message: "Session not found — reinitialize" },
@@ -1510,13 +1569,40 @@ app.all("/mcp", async (req, res) => {
1510
1569
  }
1511
1570
  const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
1512
1571
  const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
1513
- const newSessionId = randomUUID();
1572
+ // If the client brought a stale id on an initialize, reuse it so the client
1573
+ // never has to swap ids. Otherwise mint a fresh one.
1574
+ const newSessionId = existingSessionId ?? randomUUID();
1514
1575
  const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
1515
1576
  const port = req.socket.remotePort;
1516
- // Build a distinguishable client id: tag wins if set; otherwise use host+port
1517
- // so two untagged sessions from the same machine are still distinguishable.
1518
- const clientId = sessionTag
1577
+ // Pull clientInfo and workspace from the initialize body immediately so the
1578
+ // pill shows a readable name from the start.
1579
+ const initBody = Array.isArray(req.body)
1580
+ ? req.body.find((m) => m?.method === "initialize")
1581
+ : req.body;
1582
+ const earlyClientName = initBody?.params?.clientInfo?.name;
1583
+ // Prefer the workspace folder name (e.g. "AlphaWave", "notify-mcp-src") over
1584
+ // the generic client name ("claude-code"). workspaceFolders[0].name is set by
1585
+ // Claude Code and Cursor; rootUri is the fallback.
1586
+ const workspaceFolders = initBody?.params?.workspaceFolders;
1587
+ const rootUri = initBody?.params?.rootUri ?? initBody?.params?.root_uri;
1588
+ const workspaceName = workspaceFolders?.[0]?.name ||
1589
+ (rootUri ? rootUri.replace(/\\/g, "/").split("/").filter(Boolean).pop() : undefined);
1590
+ // Build a distinguishable client id: tag wins if set; workspace name next;
1591
+ // then clientInfo.name; otherwise use host+port. If the base id is already
1592
+ // taken, append -2, -3, … so two windows on the same project still show up.
1593
+ const baseId = sessionTag
1594
+ ?? workspaceName
1595
+ ?? earlyClientName
1519
1596
  ?? (host && port ? `${host === "127.0.0.1" || host === "::1" ? "local" : host}:${port}` : `sess-${newSessionId.slice(0, 8)}`);
1597
+ // Exclude the session being re-adopted from the "taken" set — it's about to
1598
+ // be replaced, so its old clientId should be available for reuse.
1599
+ const adoptingId = existingSessionId && bodyIsInitialize ? existingSessionId : undefined;
1600
+ const takenIds = new Set(Object.entries(sessions)
1601
+ .filter(([sid]) => sid !== adoptingId)
1602
+ .map(([, s]) => s.clientId));
1603
+ let clientId = baseId;
1604
+ for (let n = 2; takenIds.has(clientId); n++)
1605
+ clientId = `${baseId}-${n}`;
1520
1606
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
1521
1607
  transport.onclose = () => {
1522
1608
  if (transport.sessionId) {
@@ -1535,6 +1621,9 @@ app.all("/mcp", async (req, res) => {
1535
1621
  const now = Date.now();
1536
1622
  sessions[transport.sessionId] = {
1537
1623
  clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
1624
+ clientName: earlyClientName,
1625
+ clientVersion: initBody?.params?.clientInfo?.version,
1626
+ workspaceName,
1538
1627
  };
1539
1628
  }
1540
1629
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
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
@@ -613,39 +613,66 @@ function renderLogEntry(raw) {
613
613
  if (atBottom) panel.scrollTop = panel.scrollHeight;
614
614
  }
615
615
 
616
+ function sessionStatus(lastSeen) {
617
+ const age = Date.now() - lastSeen;
618
+ if (age < 35_000) return "live";
619
+ if (age < 95_000) return "idle";
620
+ return "stale";
621
+ }
622
+
623
+ async function dismissSession(clientId) {
624
+ await fetch(`/api/sessions/${encodeURIComponent(clientId)}`, { method: "DELETE" });
625
+ refreshSessions();
626
+ }
627
+
616
628
  async function refreshSessions() {
617
629
  try {
618
630
  const res = await fetch("/api/sessions");
619
631
  if (!res.ok) return;
620
632
  const { sessions } = await res.json();
621
633
  const bar = $("session-pills");
622
- // Build a set of desired pills (keyed by clientId). Keep the "All" pill.
623
634
  const existing = new Map();
624
- bar.querySelectorAll(".pill").forEach(p => existing.set(p.dataset.client || "", p));
635
+ bar.querySelectorAll(".pill[data-client]").forEach(p => {
636
+ if (p.dataset.client !== "") existing.set(p.dataset.client, p);
637
+ });
625
638
  const desired = new Set([""]);
626
639
  for (const s of sessions) desired.add(s.clientId);
627
- // Remove pills for disconnected sessions.
640
+
641
+ // Remove pills for sessions the server no longer knows about.
628
642
  for (const [id, el] of existing) {
629
643
  if (!desired.has(id)) el.remove();
630
644
  }
631
- // Add pills for new sessions.
645
+
646
+ // Add or update pills.
632
647
  for (const s of sessions) {
648
+ const status = sessionStatus(s.lastSeen);
649
+ const label = s.tag ? `@${s.tag}` : s.clientId;
650
+ const title = [s.workspaceName ?? s.clientName, s.host, `last seen ${Math.round((Date.now() - s.lastSeen) / 1000)}s ago`].filter(Boolean).join(" · ");
651
+
633
652
  if (!existing.has(s.clientId)) {
634
653
  const btn = document.createElement("button");
635
654
  btn.className = "pill";
636
655
  btn.dataset.client = s.clientId;
637
- btn.textContent = s.tag ? `@${s.tag}` : s.clientId;
638
- btn.title = [s.clientName, s.host].filter(Boolean).join(" · ");
639
656
  btn.onclick = () => selectLogFilter(s.clientId);
640
657
  bar.appendChild(btn);
658
+ existing.set(s.clientId, btn);
641
659
  }
660
+
661
+ const btn = existing.get(s.clientId);
662
+ btn.title = title;
663
+ btn.innerHTML =
664
+ `<span class="pill-dot pill-dot-${status}"></span>` +
665
+ `<span class="pill-label">${label}</span>` +
666
+ (status === "stale"
667
+ ? `<span class="pill-dismiss" title="Remove" onclick="event.stopPropagation();dismissSession('${s.clientId.replace(/'/g,"\\'")}')">×</span>`
668
+ : "");
642
669
  }
670
+
643
671
  // If the currently-selected client disconnected, fall back to "All".
644
672
  if (logFilterClient && !desired.has(logFilterClient)) {
645
673
  selectLogFilter("");
646
674
  } else {
647
- // Re-sync active state (in case pills were re-added).
648
- bar.querySelectorAll(".pill").forEach(p => {
675
+ bar.querySelectorAll(".pill[data-client]").forEach(p => {
649
676
  p.classList.toggle("pill-active", (p.dataset.client || "") === logFilterClient);
650
677
  });
651
678
  }
@@ -231,6 +231,18 @@ main {
231
231
  border-color: var(--accent);
232
232
  color: #fff;
233
233
  }
234
+ .pill { display: inline-flex; align-items: center; gap: 5px; }
235
+ .pill-dot {
236
+ width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
237
+ }
238
+ .pill-dot-live { background: #4ade80; }
239
+ .pill-dot-idle { background: #facc15; }
240
+ .pill-dot-stale { background: #f87171; }
241
+ .pill-dismiss {
242
+ margin-left: 1px; font-size: 13px; line-height: 1;
243
+ opacity: .6; cursor: pointer;
244
+ }
245
+ .pill-dismiss:hover { opacity: 1; }
234
246
 
235
247
  /* ── Cards ───────────────────────────────────────────────────────────────── */
236
248