omni-notify-mcp 1.2.1 → 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
@@ -177,7 +177,11 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
177
177
  "decision, (c) something important happened the user needs to know right " +
178
178
  "now. Idle/DND gating is handled server-side — fire notify and let the " +
179
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.",
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.",
181
185
  });
182
186
  // Thin proxy: forward a tool call to the HTTP server and return its content
183
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");
@@ -1155,13 +1173,18 @@ BEHAVIORAL RULES for every client that connects:
1155
1173
  Use ONLY for catastrophic findings or decisions that block
1156
1174
  progress. Misuse will train the user to ignore your notifs.
1157
1175
 
1158
- 3. Echo the full message body in your own chat / conversation output as well
1159
- as sending it through 'notify'. The user may be reading the terminal
1160
- 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.
1161
1181
 
1162
1182
  4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
1163
- 'email', etc. in your messages those are server delivery details the
1164
- 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.
1165
1188
 
1166
1189
  5. When the user sends you an unsolicited message (visible as INBOX items in
1167
1190
  the 'notify' response, via 'poll', via 'wait_for_inbox', via
@@ -1256,7 +1279,9 @@ BEHAVIORAL RULES for every client that connects:
1256
1279
  MCP client is broken, you owe them the bypass — not an excuse.
1257
1280
  `.trim();
1258
1281
  function createMcpServer(clientId, sessionTag) {
1259
- 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 });
1260
1285
  server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
1261
1286
  "Before calling, check get_idle_seconds against get_idle_config.thresholdSeconds; " +
1262
1287
  "skip the call if the user is active (unless priority='high'). " +
@@ -1500,14 +1525,16 @@ function sessionDisplay(s) {
1500
1525
  return s.tag ? `@${s.tag}` : s.clientId;
1501
1526
  }
1502
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"]);
1503
1529
  const existingSessionId = req.headers["mcp-session-id"];
1504
1530
  if (existingSessionId && httpTransports[existingSessionId]) {
1505
- await httpTransports[existingSessionId].handleRequest(req, res, req.body);
1531
+ const transport = httpTransports[existingSessionId];
1532
+ await transport.handleRequest(req, res, req.body);
1506
1533
  // Lazy-populate clientInfo after initialize lands on an existing session.
1507
1534
  const meta = sessions[existingSessionId];
1508
1535
  if (meta)
1509
1536
  meta.lastSeen = Date.now();
1510
- const mcpServer = httpTransports[existingSessionId].__mcpServer;
1537
+ const mcpServer = httpTransports[existingSessionId]?.__mcpServer;
1511
1538
  if (meta && !meta.clientName && mcpServer?.getClientVersion) {
1512
1539
  try {
1513
1540
  const info = mcpServer.getClientVersion();
@@ -1520,12 +1547,19 @@ app.all("/mcp", async (req, res) => {
1520
1547
  }
1521
1548
  return;
1522
1549
  }
1523
- // Spec-compliant: if the client presents a session ID we don't know about
1524
- // (server was restarted, or the transport was closed), return 404 so the
1525
- // client knows to re-initialize. The previous silent-new-session behavior
1526
- // left idle clients stuck with stale session bookkeeping until the VS Code
1527
- // window was manually reloaded.
1528
- 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) {
1529
1563
  res.status(404).json({
1530
1564
  jsonrpc: "2.0",
1531
1565
  error: { code: -32000, message: "Session not found — reinitialize" },
@@ -1535,13 +1569,40 @@ app.all("/mcp", async (req, res) => {
1535
1569
  }
1536
1570
  const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
1537
1571
  const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
1538
- 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();
1539
1575
  const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
1540
1576
  const port = req.socket.remotePort;
1541
- // Build a distinguishable client id: tag wins if set; otherwise use host+port
1542
- // so two untagged sessions from the same machine are still distinguishable.
1543
- 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
1544
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}`;
1545
1606
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
1546
1607
  transport.onclose = () => {
1547
1608
  if (transport.sessionId) {
@@ -1560,6 +1621,9 @@ app.all("/mcp", async (req, res) => {
1560
1621
  const now = Date.now();
1561
1622
  sessions[transport.sessionId] = {
1562
1623
  clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
1624
+ clientName: earlyClientName,
1625
+ clientVersion: initBody?.params?.clientInfo?.version,
1626
+ workspaceName,
1563
1627
  };
1564
1628
  }
1565
1629
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.2.1",
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