omni-notify-mcp 1.1.8 → 1.2.0

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
@@ -57,23 +57,25 @@ Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_co
57
57
  Then run the config UI to wire up your channels:
58
58
 
59
59
  ```bash
60
- npx omni-notify-mcp ui
60
+ npx omni-notify-ui
61
61
  ```
62
62
 
63
63
  Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
64
64
 
65
65
  ## What the agent gets
66
66
 
67
- Six tools, all server-configured (the agent never names a channel):
67
+ Eight tools, all server-configured (the agent never names a channel):
68
68
 
69
69
  | Tool | What it does |
70
70
  |---|---|
71
71
  | **`notify`** | Send a message to the user. Priority controls fan-out (see below). |
72
72
  | **`ask`** | Send a question and **wait** for the user's reply (Telegram, or web link via email). |
73
73
  | **`poll`** | Drain any unsolicited messages the user sent. |
74
- | **`get_idle_seconds`** | Seconds since last keyboard/mouse input. -1 if unsupported. |
75
- | **`get_idle_config`** | The server's idle-gating policy `{ enabled, thresholdSeconds }`. |
76
- | **`get_dnd_status`** | Current DND state `{ active, reason }`. |
74
+ | **`wait_for_inbox`** | **Long-poll**: block up to 55s and return the moment the user types something. The most reliable push path across every MCP client — messages come back as tool *results*, not notifications (which many clients drop). |
75
+ | **`get_idle_seconds`** | Seconds since last keyboard/mouse input. Drains inbox as a side-effect. |
76
+ | **`get_idle_config`** | The server's idle-gating policy `{ enabled, thresholdSeconds }`. Drains inbox. |
77
+ | **`get_dnd_status`** | Current DND state `{ active, reason }`. Drains inbox. |
78
+ | **`reply`** *(stdio only)* | Channels return-path — Claude Code calls this when the agent responds to a pushed channel message. Routes straight through `notify`. |
77
79
 
78
80
  Priority routing for `notify`:
79
81
 
@@ -112,8 +114,58 @@ Every agent that calls `get_idle_seconds` or `get_dnd_status` while busy gets an
112
114
  ### Multi-session broadcast
113
115
  When multiple agents connect to the same server (e.g. one Claude per repo), every untagged user message is broadcast to all of them. Each agent replies with its session id, the user picks who they want to address, then targets follow-ups with `@<tag>`. The Telegram ack names the sessions the message was routed to.
114
116
 
117
+ ### Dual transport — HTTP and stdio (with Claude Code Channels)
118
+ `notify-mcp` ships two entrypoints against the same server state:
119
+
120
+ - **`omni-notify-mcp`** (stdio) — the default `npx omni-notify-mcp` command. Speaks stdio JSON-RPC, auto-spawns the HTTP server as a detached child if it isn't already running, and subscribes to the inbox SSE stream so it can push unsolicited messages to the attached agent. **Declares the `claude/channel` capability**, so Claude Code v2.1.80+ surfaces each user message as a synthetic turn via `notifications/claude/channel` — the only push path that crosses the client boundary reliably ([modelcontextprotocol#1192](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1192), [claude-code/channels](https://code.claude.com/docs/en/channels)). When the host doesn't support Channels, the bridge still works — agents just use `wait_for_inbox` as the long-poll fallback.
121
+ - **`omni-notify-ui`** (HTTP, default `:3737`) — runs the config web UI, the Telegram listener, all channel implementations, and the Streamable-HTTP `/mcp` endpoint for remote / multi-session agents.
122
+
123
+ For Claude Code with Channels:
124
+ ```bash
125
+ claude --channels omni-notify-mcp
126
+ # or, during preview, if your plugin isn't allowlisted:
127
+ claude --dangerously-load-development-channels omni-notify-mcp
128
+ ```
129
+
130
+ For every other MCP client, the stdio command works as a plain MCP server:
131
+ ```json
132
+ {
133
+ "mcpServers": {
134
+ "notify": { "command": "npx", "args": ["omni-notify-mcp"] }
135
+ }
136
+ }
137
+ ```
138
+
139
+ ### Reliable push — `wait_for_inbox` long-poll
140
+ The hard truth: **most MCP clients silently drop generic server notifications** ([modelcontextprotocol#1192](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1192), [claude-code#41733](https://github.com/anthropics/claude-code/issues/41733)). The only delivery paths that survive are (a) Claude Code's new Channels (`notifications/claude/channel`, handled by the stdio bridge above) and (b) tool *results*. `wait_for_inbox` is the universal fallback: the agent calls it, the server parks the request until the user types something, then resolves it with the message — which the client is forced to surface because it's a tool-call response. Default timeout is 50s to stay under the 60s JS SDK request ceiling ([typescript-sdk#245](https://github.com/modelcontextprotocol/typescript-sdk/issues/245)); the agent re-calls on empty in a tight loop. The MCP `instructions` block shipped with the server tells agents exactly this loop pattern, so no per-prompt nagging is needed.
141
+
115
142
  ### Reconnect resilience
116
- The server returns HTTP 404 on requests with a stale `mcp-session-id` (per the MCP Streamable HTTP spec), so a client that wakes up after a server restart automatically re-initializes on its next tool call instead of staying stuck with a dead session. Idle sessions are reaped after 10 minutes of inactivity so the session list and clients pill bar stay honest.
143
+ The server returns HTTP 404 on requests with a stale `mcp-session-id` (per the MCP Streamable HTTP spec), so a client that wakes up after a server restart automatically re-initializes on its next tool call instead of staying stuck with a dead session. Idle sessions are reaped aggressively (90-second timeout, since the heartbeat contract requires requests every 15–30s), dead SSE subscribers are pruned every 15s, and every broadcast ack runs a liveness probe before counting targets — so "Broadcast to N session(s)" always reflects who can actually receive. TCP keepalive is enabled on every incoming socket (15s probe) and the server writes an SSE `: keepalive` comment down every live MCP GET stream every 20s, which defeats proxy idle timeouts and surfaces half-open connections within a minute ([typescript-sdk#270](https://github.com/modelcontextprotocol/typescript-sdk/issues/270)). The stdio bridge transparently re-initializes a fresh HTTP session on 404 without the agent ever seeing a failure.
144
+
145
+ ### File-drop bridge for busy agents (the /btw mechanism)
146
+ Claude Code has no public API for injecting a prompt into a running session while a tool call is executing ([anthropics/claude-code#27441](https://github.com/anthropics/claude-code/issues/27441)). The MCP heartbeat-drain already handles agents that are *voluntarily polling*, but if the agent is deep in a 5-minute `Bash` or `WebFetch` call, the piggy-back never fires. For that case, the server drops every unsolicited inbox message as a markdown file at `~/.notify-mcp/inbox/<timestamp>.md`, so a Claude Code `FileChanged` hook can surface it on the very next turn without the agent having to cooperate.
147
+
148
+ Drop this into `~/.claude/settings.json` (or a project-local `.claude/settings.json`):
149
+
150
+ ```json
151
+ {
152
+ "hooks": {
153
+ "FileChanged": [
154
+ {
155
+ "matcher": "**/.notify-mcp/inbox/*.md",
156
+ "hooks": [
157
+ {
158
+ "type": "command",
159
+ "command": "cat \"$CLAUDE_FILE_PATH\" && rm \"$CLAUDE_FILE_PATH\""
160
+ }
161
+ ]
162
+ }
163
+ ]
164
+ }
165
+ }
166
+ ```
167
+
168
+ The hook's stdout is injected as additional context on the next turn, and the `rm` clears the drop so each message fires exactly once. Stale drops older than 24h are reaped by the server automatically.
117
169
 
118
170
  ### Idle gating (anti-buzz)
119
171
  The server publishes a policy `{ enabled, thresholdSeconds }`. Agents are **instructed** (via the MCP `instructions` field, surfaced to every connecting client) to call `get_idle_seconds` first, and **skip** sending a notification if you're actively at the keyboard. They can already see what they'd send. Only fire when you've stepped away. `priority='high'` always fires.
package/dist/index.js CHANGED
@@ -1,146 +1,313 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * Stdio bridge for notify-mcp.
4
+ *
5
+ * This is the Claude Code / Cursor / Codex stdio entrypoint. It is a *thin*
6
+ * foreground process — all state (config, inbox, pending asks, Telegram
7
+ * listener) lives in the long-running HTTP server (`ui/server.js`, default
8
+ * port 3737). The bridge:
9
+ *
10
+ * 1. Ensures the HTTP server is running (auto-spawns it detached if not).
11
+ * 2. Subscribes to /api/inbox/stream via SSE and re-emits each unsolicited
12
+ * user message as a `notifications/claude/channel` notification to the
13
+ * attached client. Claude Code (v2.1.80+) surfaces those as synthetic
14
+ * user turns, which is the only push path that reliably crosses the
15
+ * client boundary — regular MCP notifications are dropped by most
16
+ * clients (modelcontextprotocol/modelcontextprotocol#1192).
17
+ * 3. Exposes the full tool surface (`notify`, `ask`, `poll`, `wait_for_inbox`,
18
+ * `get_idle_seconds`, `get_idle_config`, `get_dnd_status`, `reply`) and
19
+ * proxies every call to the HTTP /mcp endpoint so multi-session routing,
20
+ * DND, idle gating, etc. all work identically to the HTTP transport.
21
+ *
22
+ * The net effect: users can `claude --channels notify-mcp@<registry>` (or
23
+ * run `npx omni-notify-mcp` as a plain stdio MCP server) and get push-to-agent
24
+ * delivery without ever touching a .mcp.json or a port number.
25
+ */
2
26
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
27
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { randomUUID } from "crypto";
28
+ import { spawn } from "child_process";
29
+ import { existsSync } from "fs";
30
+ import { join, dirname } from "path";
31
+ import { fileURLToPath } from "url";
5
32
  import { z } from "zod";
6
- import { loadConfig } from "./config.js";
7
- import { sendDesktop } from "./channels/desktop.js";
8
- import { sendTelegram } from "./channels/telegram.js";
9
- import { sendWhatsApp } from "./channels/whatsapp.js";
10
- import { sendSms } from "./channels/sms.js";
11
- import { sendEmail } from "./channels/email.js";
12
- const config = loadConfig();
13
- // ── Telegram listener ─────────────────────────────────────────────────────────
14
- const pendingAsks = new Map();
15
- const inboxQueue = [];
16
- let tgPollOffset = -1;
17
- async function initTgOffset(token) {
18
- const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
19
- const json = await r.json();
20
- const results = json.result ?? [];
21
- return results.length > 0 ? results[results.length - 1].update_id + 1 : 0;
33
+ const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
34
+ const BASE = `http://localhost:${PORT}`;
35
+ const SESSION_TAG = (process.env.NOTIFY_MCP_TAG ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
36
+ const CLIENT_NAME = "claude-channel-bridge";
37
+ // ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
38
+ async function serverIsUp() {
39
+ try {
40
+ const r = await fetch(`${BASE}/mcp`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" },
43
+ body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
44
+ signal: AbortSignal.timeout(1500),
45
+ });
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;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ function spawnUiServerIfNeeded() {
55
+ const here = dirname(fileURLToPath(import.meta.url));
56
+ // dist/ layout: src/index.ts → dist/index.js; ui → dist/ui/server.js
57
+ const candidates = [
58
+ join(here, "ui", "server.js"),
59
+ join(here, "..", "ui", "server.js"),
60
+ join(here, "..", "dist", "ui", "server.js"),
61
+ ];
62
+ const uiPath = candidates.find(p => existsSync(p));
63
+ if (!uiPath) {
64
+ stderr(`[bridge] could not locate ui/server.js near ${here} — skipping auto-spawn`);
65
+ return;
66
+ }
67
+ stderr(`[bridge] auto-spawning UI server: ${uiPath}`);
68
+ const child = spawn(process.execPath, [uiPath], {
69
+ detached: true,
70
+ stdio: "ignore",
71
+ env: { ...process.env, PORT: String(PORT) },
72
+ });
73
+ child.unref();
74
+ }
75
+ async function waitForServer(maxMs = 15_000) {
76
+ const deadline = Date.now() + maxMs;
77
+ while (Date.now() < deadline) {
78
+ if (await serverIsUp())
79
+ return true;
80
+ await new Promise(r => setTimeout(r, 500));
81
+ }
82
+ return false;
22
83
  }
23
- async function startTelegramListener() {
84
+ function stderr(line) {
85
+ // stdio transport uses stdout for JSON-RPC — all logging MUST go to stderr.
86
+ try {
87
+ process.stderr.write(`${line}\n`);
88
+ }
89
+ catch { /* ignore */ }
90
+ }
91
+ // ── 2. HTTP /mcp session — the bridge itself is an MCP client of the server ──
92
+ // We run a single persistent MCP-over-HTTP session per bridge process and
93
+ // forward every local stdio tool call through it. Using one shared session
94
+ // (not per-tool-call) keeps the tag-based routing, waiter parking, and inbox
95
+ // draining all consistent with what the HTTP server sees.
96
+ let httpSessionId;
97
+ let httpRpcId = 1;
98
+ async function httpRpc(method, params, isNotification = false) {
99
+ // JSON-RPC notifications (method name starts with `notifications/` or the
100
+ // caller says so) carry no `id` and get no response. Spec-compliant servers
101
+ // return 202 Accepted with empty body.
102
+ const notif = isNotification || method.startsWith("notifications/");
103
+ const body = { jsonrpc: "2.0", method, params: params ?? {} };
104
+ if (!notif)
105
+ body.id = httpRpcId++;
106
+ const headers = {
107
+ "Content-Type": "application/json",
108
+ "Accept": "application/json, text/event-stream",
109
+ };
110
+ if (httpSessionId)
111
+ headers["mcp-session-id"] = httpSessionId;
112
+ const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
113
+ const r = await fetch(`${BASE}/mcp${tagQuery}`, {
114
+ method: "POST",
115
+ headers,
116
+ body: JSON.stringify(body),
117
+ // Long-poll tools may block up to ~55s server-side. Give the fetch
118
+ // generous headroom but not infinite, so a wedged server surfaces fast.
119
+ signal: AbortSignal.timeout(120_000),
120
+ });
121
+ // The server returns 404 when our cached session is stale (spec-compliant
122
+ // behavior after a server restart). Clear and retry once with a fresh init.
123
+ if (r.status === 404 && httpSessionId) {
124
+ httpSessionId = undefined;
125
+ await httpInitialize();
126
+ return httpRpc(method, params, isNotification);
127
+ }
128
+ if (r.status >= 500) {
129
+ throw new Error(`HTTP ${r.status} from /mcp: ${await r.text().catch(() => "")}`);
130
+ }
131
+ const sid = r.headers.get("mcp-session-id");
132
+ if (sid && !httpSessionId)
133
+ httpSessionId = sid;
134
+ if (notif)
135
+ return undefined;
136
+ const ctype = r.headers.get("content-type") ?? "";
137
+ const raw = await r.text();
138
+ if (ctype.includes("application/json")) {
139
+ return JSON.parse(raw);
140
+ }
141
+ // SSE framing: pull the first data: line as the JSON-RPC response.
142
+ for (const line of raw.split(/\r?\n/)) {
143
+ if (line.startsWith("data:")) {
144
+ const json = line.slice(5).trim();
145
+ if (json)
146
+ return JSON.parse(json);
147
+ }
148
+ }
149
+ throw new Error(`unexpected response from /mcp: ${raw.slice(0, 200)}`);
150
+ }
151
+ async function httpInitialize() {
152
+ const res = await httpRpc("initialize", {
153
+ protocolVersion: "2024-11-05",
154
+ capabilities: {},
155
+ clientInfo: { name: CLIENT_NAME, version: "1.0" },
156
+ });
157
+ if (res?.error)
158
+ throw new Error(`initialize failed: ${JSON.stringify(res.error)}`);
159
+ // Follow-up: the spec requires a notifications/initialized after initialize.
160
+ await httpRpc("notifications/initialized").catch(() => { });
161
+ }
162
+ // ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
163
+ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
164
+ // Declare the claude/channel capability so Claude Code knows to surface
165
+ // our notifications/claude/channel events as synthetic user turns. The
166
+ // `reply` tool below is how Claude hands the agent's response back to us.
167
+ // Reference: https://code.claude.com/docs/en/channels-reference
168
+ capabilities: {
169
+ experimental: { "claude/channel": {} },
170
+ },
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.",
175
+ });
176
+ // Thin proxy: forward a tool call to the HTTP server and return its content
177
+ // block array verbatim. Error shape matches what the SDK expects from tool
178
+ // handlers.
179
+ async function proxyToolCall(name, args) {
180
+ const res = await httpRpc("tools/call", { name, arguments: args });
181
+ if (res?.error) {
182
+ return { content: [{ type: "text", text: `Error: ${res.error.message ?? JSON.stringify(res.error)}` }], isError: true };
183
+ }
184
+ const result = res?.result;
185
+ if (result?.content && Array.isArray(result.content)) {
186
+ return { content: result.content, isError: !!result.isError };
187
+ }
188
+ return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
189
+ }
190
+ server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured.", {
191
+ message: z.string().max(500),
192
+ priority: z.enum(["low", "normal", "high"]).default("normal"),
193
+ }, async (args) => proxyToolCall("notify", args));
194
+ server.tool("ask", "Send a question to the user and wait for their reply.", {
195
+ question: z.string().max(500),
196
+ timeout_seconds: z.number().min(30).max(3600).default(300),
197
+ }, async (args) => proxyToolCall("ask", args));
198
+ server.tool("poll", "Drain pending unsolicited user messages.", {}, async () => proxyToolCall("poll", {}));
199
+ server.tool("wait_for_inbox", "Block until an unsolicited user message arrives or timeout expires. Reliable " +
200
+ "delivery across every MCP client (messages come back as tool results).", {
201
+ timeout_seconds: z.number().min(5).max(55).default(50),
202
+ }, async (args) => proxyToolCall("wait_for_inbox", args));
203
+ server.tool("get_idle_seconds", "Seconds since user's last keyboard/mouse input. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_seconds", {}));
204
+ server.tool("get_idle_config", "Server's idle gating policy. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_idle_config", {}));
205
+ server.tool("get_dnd_status", "Current DND state. Drains inbox as a side-effect.", {}, async () => proxyToolCall("get_dnd_status", {}));
206
+ // `reply` is the Channels return-path: Claude Code invokes it when the agent
207
+ // has produced a response to a channel-delivered user message. We just funnel
208
+ // it straight through `notify` so it flows to whatever channel the user is
209
+ // actually reading (Telegram, desktop, email, ...).
210
+ server.tool("reply", "Reply to the user's most recent channel message. Routes through notify so " +
211
+ "the response reaches whichever channel the user is reading.", {
212
+ message: z.string().max(2000).describe("The reply text to deliver"),
213
+ priority: z.enum(["low", "normal", "high"]).default("normal"),
214
+ }, async ({ message, priority }) => {
215
+ const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
216
+ return proxyToolCall("notify", { message: `${tagPrefix}${message}`, priority });
217
+ });
218
+ async function subscribeInbox() {
219
+ const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
220
+ // Reconnect forever with backoff. We don't care about replay; the
221
+ // file-drop bridge and queue handle the "missed while offline" window.
222
+ let backoff = 1000;
24
223
  while (true) {
25
224
  try {
26
- const cfg = loadConfig();
27
- const { token, chatId } = cfg.telegram ?? {};
28
- if (!token || !chatId || !cfg.telegram?.enabled) {
29
- await new Promise(r => setTimeout(r, 5000));
30
- continue;
31
- }
32
- if (tgPollOffset < 0) {
33
- tgPollOffset = await initTgOffset(token);
34
- }
35
- const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=${tgPollOffset}&timeout=10`);
36
- const json = await r.json();
37
- for (const update of json.result ?? []) {
38
- tgPollOffset = update.update_id + 1;
39
- const msg = update.message;
40
- if (msg?.chat?.id?.toString() === chatId && msg.text) {
41
- const first = [...pendingAsks.entries()][0];
42
- if (first) {
43
- const [id, pending] = first;
44
- clearTimeout(pending.timer);
45
- pendingAsks.delete(id);
46
- pending.resolve(msg.text);
47
- }
48
- else {
49
- inboxQueue.push({ text: msg.text, ts: new Date().toISOString() });
225
+ const r = await fetch(`${BASE}/api/inbox/stream${tagQuery}`, {
226
+ headers: { "Accept": "text/event-stream" },
227
+ });
228
+ if (!r.ok || !r.body)
229
+ throw new Error(`stream HTTP ${r.status}`);
230
+ backoff = 1000; // reset on successful connect
231
+ const reader = r.body.getReader();
232
+ const decoder = new TextDecoder();
233
+ let buffer = "";
234
+ while (true) {
235
+ const { value, done } = await reader.read();
236
+ if (done)
237
+ break;
238
+ buffer += decoder.decode(value, { stream: true });
239
+ let idx;
240
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
241
+ const frame = buffer.slice(0, idx);
242
+ buffer = buffer.slice(idx + 2);
243
+ // SSE frame: may include multiple "data:" lines. We emit on each.
244
+ for (const line of frame.split(/\r?\n/)) {
245
+ if (!line.startsWith("data:"))
246
+ continue;
247
+ const payload = line.slice(5).trim();
248
+ if (!payload)
249
+ continue;
250
+ try {
251
+ const entry = JSON.parse(payload);
252
+ await emitChannelEvent(entry);
253
+ }
254
+ catch (err) {
255
+ stderr(`[bridge] bad SSE payload: ${err instanceof Error ? err.message : String(err)}`);
256
+ }
50
257
  }
51
258
  }
52
259
  }
53
260
  }
54
261
  catch (err) {
55
- const msg = err instanceof Error ? err.message : String(err);
56
- if (!msg.includes("terminated") && !msg.includes("aborted")) {
57
- await new Promise(r => setTimeout(r, 2000));
58
- }
262
+ stderr(`[bridge] inbox stream closed: ${err instanceof Error ? err.message : String(err)}`);
59
263
  }
264
+ await new Promise(r => setTimeout(r, backoff));
265
+ backoff = Math.min(30_000, backoff * 2);
60
266
  }
61
267
  }
62
- startTelegramListener();
63
- // ── MCP server ────────────────────────────────────────────────────────────────
64
- const server = new McpServer({
65
- name: "omni-notify-mcp",
66
- version: "1.0.0",
67
- });
68
- server.tool("notify", "Send a notification through configured channels (desktop, Telegram, WhatsApp, SMS, email). " +
69
- "Priority: low=email only; normal=desktop+telegram+email; high=all channels. " +
70
- "Use for: task milestones, questions needing user input, catastrophic findings, long task completion.", {
71
- message: z.string().max(500).describe("Notification message, max 500 chars"),
72
- priority: z
73
- .enum(["low", "normal", "high"])
74
- .default("normal")
75
- .describe("low=email only; normal=desktop+telegram+email; high=desktop+telegram+whatsapp+sms+email"),
76
- }, async ({ message, priority }) => {
77
- const results = [];
78
- const errors = [];
79
- const send = async (name, fn) => {
80
- try {
81
- await fn();
82
- results.push(name);
83
- }
84
- catch (err) {
85
- errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
268
+ async function emitChannelEvent(entry) {
269
+ // Emit the new Claude Code Channels notification *and* the generic
270
+ // `notifications/message` as a belt-and-suspenders approach: hosts that
271
+ // ignore `notifications/claude/channel` may still surface the message as
272
+ // a log line. Both are fire-and-forget; a failure just means the peer
273
+ // closed the stdio transport (we'll notice on the next tool call).
274
+ const content = entry.tag ? `[@${entry.tag}] ${entry.text}` : entry.text;
275
+ try {
276
+ await server.server.notification({
277
+ method: "notifications/claude/channel",
278
+ params: {
279
+ content,
280
+ meta: { ts: entry.ts, tag: entry.tag ?? null, source: "notify-mcp" },
281
+ },
282
+ });
283
+ }
284
+ catch {
285
+ // client doesn't support the experimental capability — that's fine,
286
+ // wait_for_inbox is the universal fallback.
287
+ }
288
+ }
289
+ // ── 5. Wire it up ────────────────────────────────────────────────────────────
290
+ async function main() {
291
+ if (!(await serverIsUp())) {
292
+ spawnUiServerIfNeeded();
293
+ if (!(await waitForServer())) {
294
+ stderr(`[bridge] HTTP server at ${BASE} did not come up within 15s — giving up on push; tool calls will fail.`);
86
295
  }
87
- };
88
- if (priority === "normal" || priority === "high") {
89
- await send("desktop", () => sendDesktop(config.desktop, message));
90
- await send("telegram", () => sendTelegram(config.telegram, message));
91
- }
92
- if (priority === "high") {
93
- await send("whatsapp", () => sendWhatsApp(config.whatsapp, message));
94
- await send("sms", () => sendSms(config.sms, message));
95
- }
96
- await send("email", () => sendEmail(config.email, message));
97
- const summary = [
98
- results.length > 0 ? `Sent via: ${results.join(", ")}` : null,
99
- errors.length > 0 ? `Errors: ${errors.join("; ")}` : null,
100
- ].filter(Boolean).join(" | ");
101
- return { content: [{ type: "text", text: summary || "No channels delivered" }] };
102
- });
103
- server.tool("ask", "Send a question to the user via Telegram and wait for their reply. " +
104
- "Use when a decision is needed before continuing — e.g. 'Should I delete these files?'", {
105
- question: z.string().max(500).describe("The question to ask the user"),
106
- timeout_seconds: z.number().min(30).max(3600).default(300)
107
- .describe("How long to wait for a reply in seconds (default 5 min)"),
108
- }, async ({ question, timeout_seconds = 300 }) => {
109
- const cfg = loadConfig();
110
- if (!cfg.telegram?.enabled || !cfg.telegram.token || !cfg.telegram.chatId) {
111
- return { content: [{ type: "text", text: "Error: Telegram not configured. Enable it in ~/.notify-mcp/config.json" }] };
112
- }
113
- await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
114
- method: "POST",
115
- headers: { "Content-Type": "application/json" },
116
- body: JSON.stringify({
117
- chat_id: cfg.telegram.chatId,
118
- text: `❓ ${question}\n\nReply to this message with your answer.`,
119
- }),
120
- });
121
- const token = randomUUID();
122
- const reply = await new Promise((resolve, reject) => {
123
- const timer = setTimeout(() => {
124
- pendingAsks.delete(token);
125
- reject(new Error(`No reply received within ${timeout_seconds}s`));
126
- }, timeout_seconds * 1000);
127
- pendingAsks.set(token, { resolve, timer });
128
- });
129
- return { content: [{ type: "text", text: reply }] };
130
- });
131
- server.tool("poll", "Check for unsolicited messages the user sent on Telegram (not in response to an ask). " +
132
- "Returns queued messages and clears the queue. Returns 'inbox:empty' if nothing pending. " +
133
- "Call this at the start of each work cycle.", {}, async () => {
134
- if (inboxQueue.length === 0) {
135
- return { content: [{ type: "text", text: "inbox:empty" }] };
136
- }
137
- const messages = inboxQueue.splice(0);
138
- return {
139
- content: [{
140
- type: "text",
141
- text: messages.map(m => `[${m.ts}] ${m.text}`).join("\n"),
142
- }],
143
- };
296
+ }
297
+ try {
298
+ await httpInitialize();
299
+ }
300
+ catch (err) {
301
+ stderr(`[bridge] initial HTTP initialize failed: ${err instanceof Error ? err.message : String(err)}`);
302
+ }
303
+ // Fire-and-forget: the stdio transport should be usable immediately; the
304
+ // push channel attaches as soon as the SSE handshake completes.
305
+ subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
306
+ const transport = new StdioServerTransport();
307
+ await server.connect(transport);
308
+ stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
309
+ }
310
+ main().catch(err => {
311
+ stderr(`[bridge] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
312
+ process.exit(1);
144
313
  });
145
- const transport = new StdioServerTransport();
146
- await server.connect(transport);
package/dist/ui/server.js CHANGED
@@ -1,7 +1,8 @@
1
+ #!/usr/bin/env node
1
2
  import { randomUUID } from "crypto";
2
3
  import express from "express";
3
4
  import { google } from "googleapis";
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
5
6
  import { homedir, networkInterfaces } from "os";
6
7
  import { join } from "path";
7
8
  import { fileURLToPath } from "url";
@@ -747,6 +748,21 @@ function getLocalIp() {
747
748
  }
748
749
  const pendingAsks = new Map();
749
750
  const inboxQueue = [];
751
+ const inboxWaiters = new Map();
752
+ // Match waiters the same way `matchesSession` matches SSE subscribers:
753
+ // - untagged entry → every waiter is a match (broadcast)
754
+ // - tagged entry → only waiters with the same tag match
755
+ function takeWaitersFor(entryTag) {
756
+ const taken = [];
757
+ for (const [id, w] of inboxWaiters) {
758
+ const match = entryTag === undefined ? true : w.tag === entryTag;
759
+ if (match) {
760
+ inboxWaiters.delete(id);
761
+ taken.push(w);
762
+ }
763
+ }
764
+ return taken;
765
+ }
750
766
  let tgPollOffset = -1;
751
767
  let lastUserMessageId;
752
768
  // When the user pings us from Telegram, bypass idle-gating on outbound
@@ -772,19 +788,110 @@ function matchesSession(entry, sessionTag) {
772
788
  return true; // untagged → everyone
773
789
  return entry.tag === sessionTag; // tagged → only matching session
774
790
  }
791
+ // ── /btw file-drop bridge ─────────────────────────────────────────────────────
792
+ // Claude Code has no API for injecting a prompt into a running session while a
793
+ // tool call is executing (anthropics/claude-code#27441, still open). The only
794
+ // in-band channel is the `FileChanged` hook: when a watched file changes on
795
+ // disk, Claude Code's hook script stdout is injected as additional context on
796
+ // the next turn — without the agent having to poll.
797
+ //
798
+ // We drop every unsolicited user message into ~/.notify-mcp/inbox/<ts>.md, and
799
+ // ship a one-liner hook in the README that globs that directory. This is the
800
+ // closest thing to a "/btw" we can get until the client exposes a real inject
801
+ // endpoint.
802
+ const INBOX_DROP_DIR = join(CONFIG_DIR, "inbox");
803
+ const INBOX_DROP_TTL_MS = 24 * 60 * 60 * 1000; // 24h — hook should have consumed within seconds
804
+ function writeInboxDrop(entry) {
805
+ try {
806
+ if (!existsSync(INBOX_DROP_DIR))
807
+ mkdirSync(INBOX_DROP_DIR, { recursive: true });
808
+ const safeTs = entry.ts.replace(/[:.]/g, "-");
809
+ const tagPart = entry.tag ? `.${entry.tag}` : "";
810
+ const path = join(INBOX_DROP_DIR, `${safeTs}${tagPart}.md`);
811
+ const header = `# Unsolicited user message\n\n` +
812
+ `- Time: ${entry.ts}\n` +
813
+ (entry.tag ? `- Tag: @${entry.tag}\n` : "") +
814
+ `- Origin: user (out-of-band)\n\n`;
815
+ writeFileSync(path, header + entry.text + "\n");
816
+ }
817
+ catch (err) {
818
+ log("·", "inbox-drop", `write failed: ${err instanceof Error ? err.message : String(err)}`);
819
+ }
820
+ }
821
+ // Reap old drops so the directory doesn't grow forever. Hooks consume within
822
+ // seconds, so anything older than a day is a message the agent never saw —
823
+ // keep it for forensics but eventually clean up.
824
+ setInterval(() => {
825
+ try {
826
+ if (!existsSync(INBOX_DROP_DIR))
827
+ return;
828
+ const now = Date.now();
829
+ const files = readdirSync(INBOX_DROP_DIR);
830
+ for (const f of files) {
831
+ const p = join(INBOX_DROP_DIR, f);
832
+ try {
833
+ const st = statSync(p);
834
+ if (now - st.mtimeMs > INBOX_DROP_TTL_MS)
835
+ unlinkSync(p);
836
+ }
837
+ catch { /* ignore */ }
838
+ }
839
+ }
840
+ catch { /* ignore */ }
841
+ }, 60 * 60 * 1000);
775
842
  const inboxStreamClients = new Set();
776
843
  function broadcastInbox(entry) {
777
844
  const payload = JSON.stringify(entry);
845
+ let delivered = 0;
778
846
  for (const c of inboxStreamClients) {
847
+ // Proactively drop subscribers whose socket is gone. Node's req.on("close")
848
+ // isn't reliable on every disconnect path (e.g. VS Code window killed hard,
849
+ // laptop lid shut), so check writability before every write.
850
+ if (c.res.destroyed || c.res.writableEnded || !c.res.writable) {
851
+ inboxStreamClients.delete(c);
852
+ continue;
853
+ }
779
854
  if (!matchesSession(entry, c.tag))
780
855
  continue;
781
856
  try {
782
857
  c.res.write(`data: ${payload}\n\n`);
858
+ delivered++;
783
859
  }
784
860
  catch {
785
- // drop on failure; the close handler will remove the client
861
+ inboxStreamClients.delete(c);
786
862
  }
787
863
  }
864
+ return delivered;
865
+ }
866
+ // Test-only: inject a fake inbox entry exactly as the Telegram listener would.
867
+ // Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
868
+ // production run. Used by the test suite to drive wait_for_inbox wake-up and
869
+ // SSE broadcast paths without needing a real Telegram bot.
870
+ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
871
+ app.post("/__test__/inject-inbox", express.json(), (req, res) => {
872
+ const text = String(req.body?.text ?? "");
873
+ const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
874
+ if (!text) {
875
+ res.status(400).json({ error: "text required" });
876
+ return;
877
+ }
878
+ const entry = { text, ts: new Date().toISOString(), tag };
879
+ const waiters = takeWaitersFor(tag);
880
+ if (waiters.length > 0) {
881
+ for (const w of waiters) {
882
+ clearTimeout(w.timer);
883
+ w.resolve([entry]);
884
+ }
885
+ }
886
+ else {
887
+ inboxQueue.push(entry);
888
+ }
889
+ const sse = broadcastInbox(entry);
890
+ writeInboxDrop(entry);
891
+ log("·", "test-inject", `${text} (waiters=${waiters.length}, sse=${sse})`, tag);
892
+ res.json({ injected: true, waiters: waiters.length, sse });
893
+ });
894
+ log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox enabled");
788
895
  }
789
896
  app.get("/api/inbox/stream", (req, res) => {
790
897
  res.setHeader("Content-Type", "text/event-stream");
@@ -885,9 +992,30 @@ async function startTelegramListener() {
885
992
  const entry = {
886
993
  text, ts: new Date().toISOString(), messageId: msg.message_id, tag,
887
994
  };
888
- inboxQueue.push(entry);
889
- broadcastInbox(entry);
890
- log("·", "inbox", text, tag);
995
+ // Waiters (wait_for_inbox long-poll) get first crack — they were
996
+ // already parked by an agent explicitly asking "wake me up when
997
+ // something arrives." Hand the entry off as a tool *result*, which
998
+ // every MCP client actually surfaces. Only queue if no one was
999
+ // waiting, so the message isn't delivered twice.
1000
+ const waiters = takeWaitersFor(tag);
1001
+ if (waiters.length > 0) {
1002
+ for (const w of waiters) {
1003
+ clearTimeout(w.timer);
1004
+ w.resolve([entry]);
1005
+ }
1006
+ log("·", "inbox", `${text} → ${waiters.length} long-poll waiter(s)`, tag);
1007
+ }
1008
+ else {
1009
+ inboxQueue.push(entry);
1010
+ }
1011
+ writeInboxDrop(entry);
1012
+ const liveSseCount = broadcastInbox(entry);
1013
+ log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
1014
+ // Before building the ack, prune sessions whose transport stream
1015
+ // is dead or whose heartbeat has lapsed. Without this the ack
1016
+ // cheerfully claims "broadcast to 3 sessions" when 2 of them are
1017
+ // closed VS Code windows — which is exactly what prompted this fix.
1018
+ pruneDeadSessions();
891
1019
  // Build an ack that names the active sessions the user's message
892
1020
  // is being routed to. If the user tagged it and no session with
893
1021
  // that tag is connected, tell them plainly so they don't sit
@@ -1011,8 +1139,9 @@ BEHAVIORAL RULES for every client that connects:
1011
1139
  user has already configured. Say 'notif' or 'notification' instead.
1012
1140
 
1013
1141
  5. When the user sends you an unsolicited message (visible as INBOX items in
1014
- the 'notify' response, via 'poll', via 'get_idle_seconds' piggy-back, or
1015
- via the /api/inbox/stream SSE), reply to them THROUGH 'notify' so the reply
1142
+ the 'notify' response, via 'poll', via 'wait_for_inbox', via
1143
+ 'get_idle_seconds' piggy-back, or via the /api/inbox/stream SSE), reply to
1144
+ them THROUGH 'notify' so the reply
1016
1145
  actually reaches them — not just in your chat output. Multiple agents may
1017
1146
  be connected simultaneously — the server broadcasts every untagged inbox
1018
1147
  message to all of them, so the user can see who is listening. Your reply
@@ -1046,6 +1175,16 @@ BEHAVIORAL RULES for every client that connects:
1046
1175
  hours away during long work. Treat 'get_idle_seconds' as the "check for
1047
1176
  user input" primitive, not an idle-gate check.
1048
1177
 
1178
+ If your work is naturally idle (waiting for the user, between loop iters),
1179
+ prefer 'wait_for_inbox' instead — it blocks up to 50s and returns the
1180
+ moment the user types anything, as a tool result. That's the most reliable
1181
+ delivery path across every MCP client (notifications over SSE are silently
1182
+ dropped by Claude Code, Cursor, and others). Loop pattern:
1183
+ while (true) {
1184
+ const r = await wait_for_inbox({ timeout_seconds: 50 });
1185
+ if (r !== "inbox:empty") handle(r);
1186
+ }
1187
+
1049
1188
  7. If your tool call fails with "MCP server not connected" / "transport
1050
1189
  closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
1051
1190
  connected to the same server right now. Only YOUR client's transport
@@ -1194,6 +1333,37 @@ function createMcpServer(clientId, sessionTag) {
1194
1333
  }],
1195
1334
  };
1196
1335
  });
1336
+ server.tool("wait_for_inbox", "Block until the user sends an unsolicited message, or until the timeout " +
1337
+ "expires. Returns the message(s) as tool results (the reliable MCP delivery " +
1338
+ "path — notifications over SSE are dropped by many clients). Default timeout " +
1339
+ "is 50s to stay under the JS SDK's 60s request timeout; keep the agent-side " +
1340
+ "loop re-calling on empty so a quiet user doesn't leak an abandoned waiter. " +
1341
+ "If messages are already queued for this session, returns them immediately.", {
1342
+ timeout_seconds: z.number().min(5).max(55).default(50)
1343
+ .describe("How long to block before returning empty (5-55s)"),
1344
+ }, async ({ timeout_seconds = 50 }) => {
1345
+ // Fast-path: if there are already messages queued for this session tag,
1346
+ // drain and return them without parking a waiter.
1347
+ const queued = drainInboxFor(sessionTag);
1348
+ if (queued.length > 0) {
1349
+ const body = queued.map(m => `[${m.ts}] ${m.text}`).join("\n");
1350
+ return { content: [{ type: "text", text: `⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${body}` }] };
1351
+ }
1352
+ const token = randomUUID();
1353
+ const entries = await new Promise((resolve) => {
1354
+ const timer = setTimeout(() => {
1355
+ inboxWaiters.delete(token);
1356
+ resolve([]);
1357
+ }, timeout_seconds * 1000);
1358
+ inboxWaiters.set(token, { resolve, timer, tag: sessionTag });
1359
+ });
1360
+ if (entries.length === 0) {
1361
+ return { content: [{ type: "text", text: "inbox:empty" }] };
1362
+ }
1363
+ log("·", "wait_for_inbox", `${entries.length} message(s) delivered`, clientId);
1364
+ const body = entries.map(m => `[${m.ts}] ${m.text}`).join("\n");
1365
+ return { content: [{ type: "text", text: `⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${body}` }] };
1366
+ });
1197
1367
  server.tool("get_idle_seconds", "Returns the number of seconds since the user's last keyboard/mouse input. " +
1198
1368
  "Call this periodically during long work as a cheap heartbeat — the server " +
1199
1369
  "will piggy-back any pending inbox messages in the response, so you stay " +
@@ -1232,7 +1402,12 @@ const sessions = {};
1232
1402
  // list and pills bar accurate even when clients vanish without closing their
1233
1403
  // transport (VS Code window closed, laptop lid shut, network died). On next
1234
1404
  // reconnect the client gets a 404 and reinitializes cleanly.
1235
- const SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
1405
+ //
1406
+ // The MCP instructions force agents to call get_idle_seconds every 15–30s as a
1407
+ // keepalive, so any session that hasn't made *any* request in 90s is almost
1408
+ // certainly dead. Keep this tight — the whole point is that stale sessions
1409
+ // stop showing up in broadcast acks.
1410
+ const SESSION_IDLE_TIMEOUT_MS = 90 * 1000;
1236
1411
  setInterval(() => {
1237
1412
  const now = Date.now();
1238
1413
  for (const [sessionId, meta] of Object.entries(sessions)) {
@@ -1246,7 +1421,16 @@ setInterval(() => {
1246
1421
  delete sessions[sessionId];
1247
1422
  }
1248
1423
  }
1249
- }, 60_000);
1424
+ // Prune SSE inbox subscribers whose underlying socket has died. Node
1425
+ // surfaces dead sockets as destroyed/writableEnded — if we don't sweep
1426
+ // these, broadcastInbox quietly writes to ghosts and the ack count lies
1427
+ // to the user ("Broadcast to 3 sessions" when there's really one).
1428
+ for (const c of inboxStreamClients) {
1429
+ if (c.res.destroyed || c.res.writableEnded || !c.res.writable) {
1430
+ inboxStreamClients.delete(c);
1431
+ }
1432
+ }
1433
+ }, 15_000);
1250
1434
  function listActiveSessions() {
1251
1435
  return Object.values(sessions);
1252
1436
  }
@@ -1255,6 +1439,38 @@ function sessionsMatchingTag(tag) {
1255
1439
  return listActiveSessions();
1256
1440
  return listActiveSessions().filter(s => s.tag === tag);
1257
1441
  }
1442
+ // Synchronous best-effort liveness check before we count sessions in an ack.
1443
+ // The transport's SDK doesn't expose a "ping" API, but it does hold a ref to
1444
+ // the response stream of the last GET the client opened — if that stream is
1445
+ // destroyed/ended, the client is gone. We also use the `lastSeen` shortcut:
1446
+ // if a session hasn't made a request in more than (idle+grace) seconds and
1447
+ // the MCP instructions require a 15-30s heartbeat, it's dead. Be lenient —
1448
+ // false-positives here result in lying to the user; false-negatives just
1449
+ // cause a harmless write that the next reap will clean up.
1450
+ const LIVE_GRACE_MS = 60_000;
1451
+ function pruneDeadSessions() {
1452
+ const now = Date.now();
1453
+ for (const [sessionId, meta] of Object.entries(sessions)) {
1454
+ const stale = now - meta.lastSeen > LIVE_GRACE_MS;
1455
+ const transport = httpTransports[sessionId];
1456
+ // The SDK stashes the active response stream on the transport for server-
1457
+ // sent notifications. If it exists and is dead, prune. Guarded because
1458
+ // the internal field name isn't stable across SDK versions.
1459
+ const streams = [transport?._streams, transport?._responseStreams, transport?._sseResponse]
1460
+ .filter(Boolean)
1461
+ .flatMap(s => (s instanceof Map ? [...s.values()] : Array.isArray(s) ? s : [s]));
1462
+ const deadStream = streams.length > 0 && streams.every(r => r?.destroyed || r?.writableEnded || r?.writable === false);
1463
+ if (stale || deadStream) {
1464
+ log("·", "session", `pruned unresponsive session ${meta.clientId} (stale=${stale}, deadStream=${deadStream})`);
1465
+ try {
1466
+ httpTransports[sessionId]?.close();
1467
+ }
1468
+ catch { /* ignore */ }
1469
+ delete httpTransports[sessionId];
1470
+ delete sessions[sessionId];
1471
+ }
1472
+ }
1473
+ }
1258
1474
  function sessionDisplay(s) {
1259
1475
  return s.tag ? `@${s.tag}` : s.clientId;
1260
1476
  }
@@ -1323,10 +1539,83 @@ app.all("/mcp", async (req, res) => {
1323
1539
  }
1324
1540
  });
1325
1541
  // ── Start ─────────────────────────────────────────────────────────────────────
1326
- app.listen(PORT, "0.0.0.0", () => {
1542
+ const httpServer = app.listen(PORT, "0.0.0.0", () => {
1327
1543
  const ip = getLocalIp();
1328
1544
  console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
1329
1545
  console.log(` MCP endpoint (remote) → http://${ip}:${PORT}/mcp\n`);
1330
1546
  startTelegramListener();
1331
1547
  open(`http://localhost:${PORT}`).catch(() => { });
1332
1548
  });
1549
+ // TCP-level keepalive on every incoming socket. Without this, a client that
1550
+ // vanishes (laptop lid, killed VS Code, WiFi drop) leaves a half-open TCP
1551
+ // connection that Node never notices — the SDK's `onclose` therefore never
1552
+ // fires and the session goes zombie. With SO_KEEPALIVE the OS probes every
1553
+ // 15s and kills the socket within a couple minutes of silence, which fires
1554
+ // our reaper and clears the session bookkeeping.
1555
+ httpServer.on("connection", (socket) => {
1556
+ socket.setKeepAlive(true, 15_000);
1557
+ });
1558
+ // keepAliveTimeout gates how long Node holds an HTTP/1.1 keep-alive idle
1559
+ // connection open before closing it. Default is 5s, which was fine for
1560
+ // short-lived requests but kills long-poll waiters and MCP GET streams
1561
+ // prematurely. Bump above the 55s long-poll ceiling so the socket stays
1562
+ // alive across the whole wait. headersTimeout must exceed it.
1563
+ httpServer.keepAliveTimeout = 75_000;
1564
+ httpServer.headersTimeout = 80_000;
1565
+ // App-level keepalive on every active MCP GET SSE stream. The SDK doesn't
1566
+ // emit any bytes on an idle stream, so intermediate proxies and some clients
1567
+ // time out the stream after ~60s of silence. We write an SSE *comment* line
1568
+ // (`: keepalive\n\n`) directly to each live response — comments are ignored
1569
+ // by SSE parsers but reset proxy idle timers and surface dead sockets as
1570
+ // write errors that we can catch and reap. Pattern is the community-standard
1571
+ // fix for typescript-sdk#270.
1572
+ setInterval(() => {
1573
+ for (const [sid, transport] of Object.entries(httpTransports)) {
1574
+ const t = transport;
1575
+ // Internal field names vary across SDK versions. Collect every candidate
1576
+ // response stream reference; the ones we find are either Response-like
1577
+ // objects with .write or Maps/arrays of them. Write-failure is the signal
1578
+ // that tells us the socket is dead.
1579
+ const candidates = [];
1580
+ for (const key of ["_streamMapping", "_streams", "_responseStreams", "_sseResponse", "_responses"]) {
1581
+ const v = t[key];
1582
+ if (!v)
1583
+ continue;
1584
+ if (v instanceof Map)
1585
+ candidates.push(...v.values());
1586
+ else if (Array.isArray(v))
1587
+ candidates.push(...v);
1588
+ else
1589
+ candidates.push(v);
1590
+ }
1591
+ let wrote = false;
1592
+ let allDead = candidates.length > 0;
1593
+ for (const r of candidates) {
1594
+ if (!r || r.destroyed || r.writableEnded || r.writable === false)
1595
+ continue;
1596
+ try {
1597
+ r.write(`: keepalive ${Date.now()}\n\n`);
1598
+ wrote = true;
1599
+ allDead = false;
1600
+ }
1601
+ catch {
1602
+ // write failed — socket is dead, move on
1603
+ }
1604
+ }
1605
+ if (candidates.length > 0 && allDead) {
1606
+ try {
1607
+ httpTransports[sid]?.close();
1608
+ }
1609
+ catch { /* ignore */ }
1610
+ delete httpTransports[sid];
1611
+ delete sessions[sid];
1612
+ }
1613
+ // Touch lastSeen on a successful keepalive write so the reaper doesn't
1614
+ // kill a session that's quietly connected but idle. lastSeen normally
1615
+ // tracks inbound requests; extending it to "stream is verified writable"
1616
+ // is fine — if the write succeeds, the client really is still there.
1617
+ if (wrote && sessions[sid]) {
1618
+ sessions[sid].lastSeen = Date.now();
1619
+ }
1620
+ }
1621
+ }, 20_000);
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.1.8",
3
+ "version": "1.2.0",
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",
7
7
  "bin": {
8
- "omni-notify-mcp": "dist/index.js"
8
+ "omni-notify-mcp": "dist/index.js",
9
+ "omni-notify-ui": "dist/ui/server.js"
9
10
  },
10
11
  "files": [
11
12
  "dist/",
@@ -47,6 +48,7 @@
47
48
  "build:ui": "tsc -p ui/tsconfig.json",
48
49
  "start": "node dist/index.js",
49
50
  "ui": "npm run build:ui && node dist/ui/server.js",
51
+ "test": "npm run build && node --test tests/*.test.mjs",
50
52
  "prepublishOnly": "npm run build:mcp"
51
53
  },
52
54
  "dependencies": {