omni-notify-mcp 1.3.12 → 1.3.13
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 +6 -1
- package/config.example.json +33 -26
- package/dist/channels/slack.js +24 -13
- package/dist/channels/sms.js +23 -3
- package/dist/channels/telegram.js +15 -7
- package/dist/chunk.js +37 -0
- package/dist/index.js +115 -24
- package/dist/ui/messaging/notificationEngine.js +3 -5
- package/dist/ui/server.js +849 -96
- package/package.json +2 -2
- package/ui/public/app.js +1232 -832
- package/ui/public/index.html +138 -67
- package/ui/public/style.css +137 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
15
|
<a href="https://www.npmjs.com/package/omni-notify-mcp"><img src="https://img.shields.io/npm/v/omni-notify-mcp.svg" alt="npm"></a>
|
|
16
|
-
<a href="https://marketplace.visualstudio.com/items?itemName=
|
|
16
|
+
<a href="https://marketplace.visualstudio.com/items?itemName=Karish911.omni-notify-mcp"><img src="https://img.shields.io/visual-studio-marketplace/v/Karish911.omni-notify-mcp?label=marketplace" alt="VS Code Marketplace"></a>
|
|
17
17
|
<img src="https://img.shields.io/badge/license-MIT-4ea3ff.svg" alt="MIT license">
|
|
18
18
|
<img src="https://img.shields.io/badge/MCP-compatible-4ea3ff.svg" alt="MCP compatible">
|
|
19
19
|
</p>
|
|
@@ -110,6 +110,11 @@ Subscribe to `GET /api/inbox/stream` (text/event-stream) to receive unsolicited
|
|
|
110
110
|
### Multi-session tagging
|
|
111
111
|
Run multiple agents against the same notify server (e.g. one Claude session in `repo-a`, another in `repo-b`). Each connects with `?tag=<name>` and the user can route a Telegram message to a specific agent by prefixing `@<name>`. Untagged messages broadcast to every session.
|
|
112
112
|
|
|
113
|
+
The stdio bridge derives its tag as `<hostname>-<workspace-folder>` unless you override it with the **`NOTIFY_MCP_TAG`** env var — set it to give a bridge a durable, human-meaningful name (e.g. `NOTIFY_MCP_TAG=frontend`). This is the only stable per-bridge name: Claude Code exposes no per-session id to MCP subprocesses, so two extension panels on the *same* workspace share a tag by default.
|
|
114
|
+
|
|
115
|
+
### Multiple panels in one window
|
|
116
|
+
Open N Claude extension panels in one VS Code window and each spawns its own bridge → its own MCP session. They share a tag (same host + workspace), but `/api/clients` lists each as a separate logical client (one per panel), disambiguated by an auto-suffixed id (`foo`, `foo-2`, …) plus `panel <n>/<total>` and a short session id. `list clients` annotates the panel count. To give a panel its own durable identity instead of an ordinal, launch it with a distinct `NOTIFY_MCP_TAG`.
|
|
117
|
+
|
|
113
118
|
### Do Not Disturb
|
|
114
119
|
- **Manual toggle** — flip it on, all `priority < high` notifs drop on the floor.
|
|
115
120
|
- **Scheduled quiet hours** — e.g. 22:00 → 08:00, configurable per-day.
|
package/config.example.json
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"desktop": {
|
|
3
|
-
"enabled": true
|
|
4
|
-
},
|
|
5
|
-
"
|
|
6
|
-
"enabled": true,
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
},
|
|
10
|
-
"sms": {
|
|
11
|
-
"enabled": true,
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
{
|
|
2
|
+
"desktop": {
|
|
3
|
+
"enabled": true
|
|
4
|
+
},
|
|
5
|
+
"telegram": {
|
|
6
|
+
"enabled": true,
|
|
7
|
+
"token": "YOUR_BOT_TOKEN",
|
|
8
|
+
"chatIds": ["123456789", "-1009876543210"]
|
|
9
|
+
},
|
|
10
|
+
"sms": {
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"accessKeyId": "YOUR_AWS_ACCESS_KEY_ID",
|
|
13
|
+
"secretAccessKey": "YOUR_AWS_SECRET_ACCESS_KEY",
|
|
14
|
+
"region": "us-east-1",
|
|
15
|
+
"originationNumber": "+1YOUR_AWS_ORIGINATION_NUMBER",
|
|
16
|
+
"to": ["+1YOUR_PERSONAL_NUMBER", "+1ANOTHER_NUMBER"]
|
|
17
|
+
},
|
|
18
|
+
"slack": {
|
|
19
|
+
"enabled": true,
|
|
20
|
+
"botToken": "xoxb-YOUR-BOT-TOKEN",
|
|
21
|
+
"channels": ["C0123456789", "C0987654321"],
|
|
22
|
+
"webhookUrl": ""
|
|
23
|
+
},
|
|
24
|
+
"email": {
|
|
25
|
+
"enabled": true,
|
|
26
|
+
"host": "smtp.gmail.com",
|
|
27
|
+
"port": 587,
|
|
28
|
+
"secure": false,
|
|
29
|
+
"user": "your-email@gmail.com",
|
|
30
|
+
"pass": "YOUR_GMAIL_APP_PASSWORD",
|
|
31
|
+
"to": "your-email@gmail.com"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/channels/slack.js
CHANGED
|
@@ -3,22 +3,33 @@ export async function sendSlack(config, message, priority = "normal", title = "C
|
|
|
3
3
|
if (!config.enabled)
|
|
4
4
|
return;
|
|
5
5
|
const emoji = EMOJI_MAP[priority] ?? EMOJI_MAP.normal;
|
|
6
|
+
const blocks = [
|
|
7
|
+
{ type: "section", text: { type: "mrkdwn", text: `${emoji} *${title}*\n${message}` } },
|
|
8
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${priority} · <!date^${Math.floor(Date.now() / 1000)}^{time}|now>` }] },
|
|
9
|
+
];
|
|
10
|
+
if (config.botToken && config.channels?.length) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
let sent = 0;
|
|
13
|
+
for (const channel of config.channels) {
|
|
14
|
+
const res = await fetch("https://slack.com/api/chat.postMessage", {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.botToken}` },
|
|
17
|
+
body: JSON.stringify({ channel, text: `${emoji} *${title}*`, blocks }),
|
|
18
|
+
});
|
|
19
|
+
const json = await res.json().catch(() => ({}));
|
|
20
|
+
if (res.ok && json.ok)
|
|
21
|
+
sent++;
|
|
22
|
+
else
|
|
23
|
+
errors.push(`${channel}: ${json.error ?? res.status}`);
|
|
24
|
+
}
|
|
25
|
+
if (sent === 0 && errors.length)
|
|
26
|
+
throw new Error(`Slack error — ${errors.join("; ")}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
6
29
|
const res = await fetch(config.webhookUrl, {
|
|
7
30
|
method: "POST",
|
|
8
31
|
headers: { "Content-Type": "application/json" },
|
|
9
|
-
body: JSON.stringify({
|
|
10
|
-
text: `${emoji} *${title}*`,
|
|
11
|
-
blocks: [
|
|
12
|
-
{
|
|
13
|
-
type: "section",
|
|
14
|
-
text: { type: "mrkdwn", text: `${emoji} *${title}*\n${message}` },
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
type: "context",
|
|
18
|
-
elements: [{ type: "mrkdwn", text: `Priority: ${priority} · <!date^${Math.floor(Date.now() / 1000)}^{time}|now>` }],
|
|
19
|
-
},
|
|
20
|
-
],
|
|
21
|
-
}),
|
|
32
|
+
body: JSON.stringify({ text: `${emoji} *${title}*`, blocks }),
|
|
22
33
|
});
|
|
23
34
|
if (!res.ok)
|
|
24
35
|
throw new Error(`Slack ${res.status}: ${await res.text()}`);
|
package/dist/channels/sms.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { PinpointSMSVoiceV2Client, SendTextMessageCommand } from "@aws-sdk/client-pinpoint-sms-voice-v2";
|
|
2
2
|
export async function sendSms(config, message) {
|
|
3
3
|
if (!config.enabled)
|
|
4
4
|
return;
|
|
5
|
-
const client =
|
|
6
|
-
|
|
5
|
+
const client = new PinpointSMSVoiceV2Client({
|
|
6
|
+
region: config.region,
|
|
7
|
+
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
|
|
8
|
+
});
|
|
9
|
+
const errors = [];
|
|
10
|
+
let sent = 0;
|
|
11
|
+
const e164 = (s) => String(s ?? "").replace(/[^\d+]/g, "");
|
|
12
|
+
for (const to of config.to ?? []) {
|
|
13
|
+
try {
|
|
14
|
+
await client.send(new SendTextMessageCommand({
|
|
15
|
+
DestinationPhoneNumber: e164(to),
|
|
16
|
+
OriginationIdentity: e164(config.originationNumber) || undefined,
|
|
17
|
+
MessageBody: message,
|
|
18
|
+
}));
|
|
19
|
+
sent++;
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
errors.push(`${to}: ${err instanceof Error ? err.message : String(err)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (sent === 0 && errors.length)
|
|
26
|
+
throw new Error(`SMS error — ${errors.join("; ")}`);
|
|
7
27
|
}
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
export async function sendTelegram(config, message) {
|
|
2
2
|
if (!config.enabled)
|
|
3
3
|
return;
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
const errors = [];
|
|
5
|
+
let sent = 0;
|
|
6
|
+
for (const chatId of config.chatIds ?? []) {
|
|
7
|
+
const res = await fetch(`https://api.telegram.org/bot${config.token}/sendMessage`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: { "Content-Type": "application/json" },
|
|
10
|
+
body: JSON.stringify({ chat_id: chatId, text: message }),
|
|
11
|
+
});
|
|
12
|
+
if (res.ok)
|
|
13
|
+
sent++;
|
|
14
|
+
else
|
|
15
|
+
errors.push(`${chatId}: ${res.status} ${await res.text()}`);
|
|
16
|
+
}
|
|
17
|
+
if (sent === 0 && errors.length)
|
|
18
|
+
throw new Error(`Telegram error — ${errors.join("; ")}`);
|
|
11
19
|
}
|
package/dist/chunk.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const NOTIFY_CHUNK_LIMIT = 500;
|
|
2
|
+
function packChunks(message, body, wordAware) {
|
|
3
|
+
const chunks = [];
|
|
4
|
+
let rest = message;
|
|
5
|
+
while (rest.length > body) {
|
|
6
|
+
let cut = body;
|
|
7
|
+
if (wordAware) {
|
|
8
|
+
const window = rest.slice(0, body + 1);
|
|
9
|
+
const brk = Math.max(window.lastIndexOf(" "), window.lastIndexOf("\n"));
|
|
10
|
+
if (brk >= Math.floor(body * 0.6))
|
|
11
|
+
cut = brk;
|
|
12
|
+
}
|
|
13
|
+
chunks.push(rest.slice(0, cut).replace(/\s+$/, ""));
|
|
14
|
+
rest = rest.slice(cut).replace(/^\s+/, "");
|
|
15
|
+
}
|
|
16
|
+
if (rest.length)
|
|
17
|
+
chunks.push(rest);
|
|
18
|
+
return chunks;
|
|
19
|
+
}
|
|
20
|
+
export function splitForNotify(message, limit = NOTIFY_CHUNK_LIMIT) {
|
|
21
|
+
if (message.length <= limit)
|
|
22
|
+
return [message];
|
|
23
|
+
let parts = Math.ceil(message.length / limit);
|
|
24
|
+
for (;;) {
|
|
25
|
+
const reserve = `(${parts}/${parts}) `.length;
|
|
26
|
+
const needed = Math.ceil(message.length / Math.max(1, limit - reserve));
|
|
27
|
+
if (needed <= parts)
|
|
28
|
+
break;
|
|
29
|
+
parts = needed;
|
|
30
|
+
}
|
|
31
|
+
const body = Math.max(1, limit - `(${parts}/${parts}) `.length);
|
|
32
|
+
let chunks = packChunks(message, body, true);
|
|
33
|
+
if (chunks.length > parts)
|
|
34
|
+
chunks = packChunks(message, body, false);
|
|
35
|
+
const total = chunks.length;
|
|
36
|
+
return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`);
|
|
37
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -31,13 +31,16 @@ import { join, dirname, basename } from "path";
|
|
|
31
31
|
import { hostname } from "os";
|
|
32
32
|
import { fileURLToPath } from "url";
|
|
33
33
|
import { z } from "zod";
|
|
34
|
+
import { splitForNotify } from "./chunk.js";
|
|
34
35
|
const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
|
|
35
36
|
const BASE = `http://localhost:${PORT}`;
|
|
36
37
|
const CLEAN_ID = (s) => s.toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
37
|
-
// NOTIFY_MCP_TAG is the explicit per-window name; otherwise
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// NOTIFY_MCP_TAG is the explicit per-window name; otherwise derive from the
|
|
39
|
+
// workspace Claude Code passes (CLAUDE_PROJECT_DIR) — falling back to the launch
|
|
40
|
+
// dir — and walk up to the nearest meaningful folder, skipping generic launcher/
|
|
41
|
+
// tool/system dirs so each window names itself after its project (e.g.
|
|
42
|
+
// "bullseyenotify" / "alphawave"), never the host ("claude-code"). Using
|
|
43
|
+
// CLAUDE_PROJECT_DIR is what lets one globally-wired bridge self-tag per window.
|
|
41
44
|
function deriveVscId() {
|
|
42
45
|
const explicit = CLEAN_ID(process.env.NOTIFY_MCP_TAG ?? "");
|
|
43
46
|
if (explicit)
|
|
@@ -47,7 +50,8 @@ function deriveVscId() {
|
|
|
47
50
|
"bin", "dist", "build", "src", "out", "node_modules",
|
|
48
51
|
"windows", "system32", "users", "appdata", "roaming", "local", "programs", "temp", "tmp",
|
|
49
52
|
]);
|
|
50
|
-
|
|
53
|
+
const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
54
|
+
let dir = root;
|
|
51
55
|
for (let i = 0; i < 5; i++) {
|
|
52
56
|
const name = CLEAN_ID(basename(dir));
|
|
53
57
|
if (name && !generic.has(name))
|
|
@@ -57,11 +61,38 @@ function deriveVscId() {
|
|
|
57
61
|
break;
|
|
58
62
|
dir = parent;
|
|
59
63
|
}
|
|
60
|
-
return CLEAN_ID(basename(
|
|
64
|
+
return CLEAN_ID(basename(root)) || "agent";
|
|
61
65
|
}
|
|
62
66
|
const VSC_ID = deriveVscId();
|
|
63
|
-
|
|
67
|
+
// In the VS Code extension Claude Code does NOT set CLAUDE_PROJECT_DIR for MCP
|
|
68
|
+
// servers, and the bridge's cwd is shared across windows (VSCODE_CWD), so the
|
|
69
|
+
// project name alone collapses every window to one tag. When no explicit tag /
|
|
70
|
+
// project dir was provided, append a short stable hash of CLAUDE_CODE_SESSION_ID
|
|
71
|
+
// (shared by a session + its subagents → they still fold) so each window is a
|
|
72
|
+
// distinct client. The extension supplies the readable workspace name separately.
|
|
73
|
+
const SESSION_SUFFIX = (() => {
|
|
74
|
+
if (process.env.NOTIFY_MCP_TAG || process.env.CLAUDE_PROJECT_DIR)
|
|
75
|
+
return "";
|
|
76
|
+
const sid = (process.env.CLAUDE_CODE_SESSION_ID || "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
|
77
|
+
return sid ? `-${sid.slice(0, 6)}` : "";
|
|
78
|
+
})();
|
|
79
|
+
const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}${SESSION_SUFFIX}`;
|
|
64
80
|
const CLIENT_NAME = "claude-channel-bridge";
|
|
81
|
+
// CLAUDE_CODE_SESSION_ID is shared by an interactive Claude session AND every
|
|
82
|
+
// subagent (Task tool) it spawns — a spawned subagent inherits the parent's id
|
|
83
|
+
// verbatim. Reporting it lets the server fold a main session + its subagents
|
|
84
|
+
// into one interactive panel, so subagents don't inflate the clients tab. Empty
|
|
85
|
+
// for non-Claude-Code hosts (Cursor/Codex) — those stay one-panel-per-bridge.
|
|
86
|
+
const HOST_SESSION_ID = (process.env.CLAUDE_CODE_SESSION_ID || "").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
87
|
+
const MCP_QUERY = (() => {
|
|
88
|
+
const p = new URLSearchParams();
|
|
89
|
+
if (SESSION_TAG)
|
|
90
|
+
p.set("tag", SESSION_TAG);
|
|
91
|
+
if (HOST_SESSION_ID)
|
|
92
|
+
p.set("hsid", HOST_SESSION_ID);
|
|
93
|
+
const s = p.toString();
|
|
94
|
+
return s ? `?${s}` : "";
|
|
95
|
+
})();
|
|
65
96
|
// ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
|
|
66
97
|
async function serverIsUp() {
|
|
67
98
|
try {
|
|
@@ -137,8 +168,7 @@ async function httpRpc(method, params, isNotification = false) {
|
|
|
137
168
|
};
|
|
138
169
|
if (httpSessionId)
|
|
139
170
|
headers["mcp-session-id"] = httpSessionId;
|
|
140
|
-
const
|
|
141
|
-
const r = await fetch(`${BASE}/mcp${tagQuery}`, {
|
|
171
|
+
const r = await fetch(`${BASE}/mcp${MCP_QUERY}`, {
|
|
142
172
|
method: "POST",
|
|
143
173
|
headers,
|
|
144
174
|
body: JSON.stringify(body),
|
|
@@ -229,15 +259,14 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
229
259
|
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
230
260
|
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
231
261
|
"Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
|
|
232
|
-
"🚨
|
|
233
|
-
"
|
|
234
|
-
"
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"shortening to a single chunk — that loses information the user explicitly needs.\n\n" +
|
|
262
|
+
"🚨 NEVER TRUNCATE — THE SERVER CHUNKS FOR YOU 🚨\n" +
|
|
263
|
+
"Send the COMPLETE message (up to 5000 chars) in a single `notify` call. Bodies over " +
|
|
264
|
+
"500 chars are automatically split server-side into numbered '(1/N) ...', '(2/N) ...' " +
|
|
265
|
+
"chunks (each ≤500 chars) and delivered in order — you no longer hand-split, and a " +
|
|
266
|
+
"`too_big` error only fires past the 5000-char cap. Decide what the user MUST see " +
|
|
267
|
+
"(every fact, file path, line number, recommendation), send it all in one call, and " +
|
|
268
|
+
"echo it IN FULL in chat. NEVER shorten or summarize to fit — that loses information " +
|
|
269
|
+
"the user explicitly needs.\n\n" +
|
|
241
270
|
"When the user asks you to remember a behavioral rule or change how you should act, " +
|
|
242
271
|
"call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
|
|
243
272
|
"so the instructions persist across sessions and context compaction.",
|
|
@@ -256,13 +285,46 @@ async function proxyToolCall(name, args) {
|
|
|
256
285
|
}
|
|
257
286
|
return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
|
|
258
287
|
}
|
|
288
|
+
async function sendNotifyChunked(message, priority) {
|
|
289
|
+
const chunks = splitForNotify(message);
|
|
290
|
+
if (chunks.length === 1)
|
|
291
|
+
return proxyToolCall("notify", { message, priority });
|
|
292
|
+
const results = [];
|
|
293
|
+
for (const chunk of chunks) {
|
|
294
|
+
results.push(await proxyToolCall("notify", { message: chunk, priority }));
|
|
295
|
+
}
|
|
296
|
+
const failed = results.find(r => r.isError);
|
|
297
|
+
if (failed)
|
|
298
|
+
return failed;
|
|
299
|
+
const texts = results.map(r => r.content?.[0]?.text ?? "");
|
|
300
|
+
const inboxMarker = "⚠️ USER SENT YOU A MESSAGE";
|
|
301
|
+
const inboxBlocks = texts
|
|
302
|
+
.map(t => { const i = t.indexOf(inboxMarker); return i === -1 ? null : t.slice(i); })
|
|
303
|
+
.filter((b) => b !== null);
|
|
304
|
+
const inboxSuffix = inboxBlocks.length ? `\n\n${inboxBlocks.join("\n\n")}` : "";
|
|
305
|
+
const delivered = texts.filter(t => t.includes("Sent via:")).length;
|
|
306
|
+
const n = chunks.length;
|
|
307
|
+
let summary;
|
|
308
|
+
if (delivered === n) {
|
|
309
|
+
summary = `Delivered as ${n} chunks (${message.length} chars).`;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const stripInbox = (t) => { const i = t.indexOf(inboxMarker); return (i === -1 ? t : t.slice(0, i)).trim(); };
|
|
313
|
+
const failedSummaries = [...new Set(texts.filter(t => !t.includes("Sent via:")).map(stripInbox))];
|
|
314
|
+
const said = failedSummaries.map(s => `"${s}"`).join(", ");
|
|
315
|
+
summary = delivered === 0
|
|
316
|
+
? `Suppressed — 0 of ${n} chunks reached any channel. Server said: ${said}`
|
|
317
|
+
: `Delivered ${delivered}/${n} chunks (${message.length} chars); ${n - delivered} reached no channel — server said: ${said}.`;
|
|
318
|
+
}
|
|
319
|
+
return { content: [{ type: "text", text: `${summary}${inboxSuffix}` }] };
|
|
320
|
+
}
|
|
259
321
|
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"
|
|
263
|
-
message: z.string().max(
|
|
322
|
+
"Messages over 500 chars are automatically split into numbered '(1/N) ...' chunks " +
|
|
323
|
+
"(each ≤500 chars) and delivered in order — send the full message in one call, never " +
|
|
324
|
+
"truncate. Hard cap 5000 chars.", {
|
|
325
|
+
message: z.string().max(5000),
|
|
264
326
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
265
|
-
}, async (
|
|
327
|
+
}, async ({ message, priority }) => sendNotifyChunked(message, priority));
|
|
266
328
|
server.tool("ask", "Send a question to the user and wait for their reply.", {
|
|
267
329
|
question: z.string().max(500),
|
|
268
330
|
timeout_seconds: z.number().min(30).max(3600).default(300),
|
|
@@ -292,7 +354,7 @@ server.tool("reply", "Reply to the user's most recent channel message. Routes th
|
|
|
292
354
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
293
355
|
}, async ({ message, priority }) => {
|
|
294
356
|
const tagPrefix = SESSION_TAG ? `[@${SESSION_TAG}] ` : "";
|
|
295
|
-
return
|
|
357
|
+
return sendNotifyChunked(`${tagPrefix}${message}`, priority);
|
|
296
358
|
});
|
|
297
359
|
async function subscribeInbox() {
|
|
298
360
|
const tagQuery = SESSION_TAG ? `?tag=${encodeURIComponent(SESSION_TAG)}` : "";
|
|
@@ -366,6 +428,31 @@ async function emitChannelEvent(entry) {
|
|
|
366
428
|
}
|
|
367
429
|
}
|
|
368
430
|
// ── 5. Wire it up ────────────────────────────────────────────────────────────
|
|
431
|
+
// When the parent (claude.exe / Cursor / Codex) dies, the OS closes our stdin
|
|
432
|
+
// pipe and the stdio transport reaches EOF. Without exiting here the bridge
|
|
433
|
+
// lives on: startSessionKeepalive() keeps pinging /mcp every 30s, so the
|
|
434
|
+
// server's lastSeen never goes stale and the 90s reaper never removes the
|
|
435
|
+
// session — a closed window leaves a phantom "panel" in the clients tab
|
|
436
|
+
// forever. On EOF we DELETE our /mcp session (instant server-side removal) and
|
|
437
|
+
// exit so the panel disappears immediately.
|
|
438
|
+
let shuttingDown = false;
|
|
439
|
+
async function shutdownOnPeerLoss(why) {
|
|
440
|
+
if (shuttingDown)
|
|
441
|
+
return;
|
|
442
|
+
shuttingDown = true;
|
|
443
|
+
stderr(`[bridge] ${why} — closing /mcp session and exiting`);
|
|
444
|
+
if (httpSessionId) {
|
|
445
|
+
try {
|
|
446
|
+
await fetch(`${BASE}/mcp`, {
|
|
447
|
+
method: "DELETE",
|
|
448
|
+
headers: { "mcp-session-id": httpSessionId },
|
|
449
|
+
signal: AbortSignal.timeout(2000),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch { /* server may already be gone; the reaper will clear it */ }
|
|
453
|
+
}
|
|
454
|
+
process.exit(0);
|
|
455
|
+
}
|
|
369
456
|
async function main() {
|
|
370
457
|
if (!(await serverIsUp())) {
|
|
371
458
|
spawnUiServerIfNeeded();
|
|
@@ -385,6 +472,10 @@ async function main() {
|
|
|
385
472
|
startSessionKeepalive();
|
|
386
473
|
const transport = new StdioServerTransport();
|
|
387
474
|
await server.connect(transport);
|
|
475
|
+
// EOF on stdin = the parent process is gone. Tear down so we don't linger as
|
|
476
|
+
// an orphan heartbeating a phantom panel.
|
|
477
|
+
process.stdin.on("end", () => { void shutdownOnPeerLoss("stdin ended (parent gone)"); });
|
|
478
|
+
process.stdin.on("close", () => { void shutdownOnPeerLoss("stdin closed (parent gone)"); });
|
|
388
479
|
stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
|
|
389
480
|
}
|
|
390
481
|
main().catch(err => {
|
|
@@ -19,12 +19,10 @@ export async function sendWithRouting(options) {
|
|
|
19
19
|
if (mode.suppressedReason === "dnd") {
|
|
20
20
|
return { delivered: [], errors: [], suppressedReason: "DND active" };
|
|
21
21
|
}
|
|
22
|
-
if (mode.suppressedReason === "idle") {
|
|
23
|
-
return { delivered: [], errors: [], suppressedReason: "Idle gated while active" };
|
|
24
|
-
}
|
|
25
22
|
const delivered = [];
|
|
26
23
|
const errors = [];
|
|
27
|
-
|
|
24
|
+
// Idle gating downgrades to desktop-only; Slack (vsc_notif channel-log) is exempt below.
|
|
25
|
+
const desktopOnly = mode.desktopOnly || mode.suppressedReason === "idle";
|
|
28
26
|
const trySend = async (name, fn) => {
|
|
29
27
|
if (!fn)
|
|
30
28
|
return;
|
|
@@ -54,7 +52,7 @@ export async function sendWithRouting(options) {
|
|
|
54
52
|
if (!desktopOnly && enableDiscord) {
|
|
55
53
|
await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
|
|
56
54
|
}
|
|
57
|
-
if (
|
|
55
|
+
if (enableSlack) {
|
|
58
56
|
await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
|
|
59
57
|
}
|
|
60
58
|
if (!desktopOnly && enableTeams) {
|