omni-notify-mcp 1.3.8 → 1.3.11
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 +17 -9
- package/dist/index.js +45 -11
- package/dist/ui/messaging/notificationEngine.js +67 -0
- package/dist/ui/messaging/types.js +1 -0
- package/dist/ui/server.js +596 -222
- package/package.json +1 -1
- package/ui/public/help.html +6 -3
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<em>Reach me on any channel. Ask me anything. Get out of my way when I'm busy.</em><br>
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
HTTP-first notification/control server with optional MCP compatibility for AI agents
|
|
10
|
+
(Claude, Copilot, Cursor, etc.): desktop, Telegram, Slack, SMS, email,
|
|
11
|
+
two-way replies, idle gating, and Do Not Disturb.
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
@@ -37,6 +37,20 @@ You step away from your machine and the AI is still working. **It needs to**:
|
|
|
37
37
|
|
|
38
38
|
## Quick start
|
|
39
39
|
|
|
40
|
+
```bash
|
|
41
|
+
npx omni-notify-ui
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
This starts the HTTP/UI server on `http://localhost:3737`.
|
|
45
|
+
|
|
46
|
+
MCP is optional. To enable the `/mcp` endpoint, start with:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ENABLE_MCP=1 npx omni-notify-ui
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or use the stdio bridge (which auto-spawns the UI with MCP enabled):
|
|
53
|
+
|
|
40
54
|
```bash
|
|
41
55
|
npx omni-notify-mcp
|
|
42
56
|
```
|
|
@@ -54,12 +68,6 @@ Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_co
|
|
|
54
68
|
}
|
|
55
69
|
```
|
|
56
70
|
|
|
57
|
-
Then run the config UI to wire up your channels:
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
npx omni-notify-ui
|
|
61
|
-
```
|
|
62
|
-
|
|
63
71
|
Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
|
|
64
72
|
|
|
65
73
|
## What the agent gets
|
package/dist/index.js
CHANGED
|
@@ -27,12 +27,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
27
27
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
28
|
import { spawn } from "child_process";
|
|
29
29
|
import { existsSync } from "fs";
|
|
30
|
-
import { join, dirname } from "path";
|
|
30
|
+
import { join, dirname, basename } from "path";
|
|
31
|
+
import { hostname } from "os";
|
|
31
32
|
import { fileURLToPath } from "url";
|
|
32
33
|
import { z } from "zod";
|
|
33
34
|
const PORT = process.env.NOTIFY_MCP_PORT ? parseInt(process.env.NOTIFY_MCP_PORT) : 3737;
|
|
34
35
|
const BASE = `http://localhost:${PORT}`;
|
|
35
|
-
const
|
|
36
|
+
const VSC_ID = (process.env.NOTIFY_MCP_TAG || basename(process.cwd())).toLowerCase().replace(/[^a-z0-9_-]/g, "");
|
|
37
|
+
const SESSION_TAG = `${hostname().toLowerCase().replace(/[^a-z0-9_-]/g, "")}-${VSC_ID}`;
|
|
36
38
|
const CLIENT_NAME = "claude-channel-bridge";
|
|
37
39
|
// ── 1. Ensure the HTTP server is up ───────────────────────────────────────────
|
|
38
40
|
async function serverIsUp() {
|
|
@@ -43,9 +45,9 @@ async function serverIsUp() {
|
|
|
43
45
|
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: {} }),
|
|
44
46
|
signal: AbortSignal.timeout(1500),
|
|
45
47
|
});
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
return r.status > 0;
|
|
48
|
+
// The bridge requires an active /mcp endpoint.
|
|
49
|
+
// 404 specifically means UI may be up but MCP transport is disabled.
|
|
50
|
+
return r.status > 0 && r.status !== 404;
|
|
49
51
|
}
|
|
50
52
|
catch {
|
|
51
53
|
return false;
|
|
@@ -68,7 +70,7 @@ function spawnUiServerIfNeeded() {
|
|
|
68
70
|
const child = spawn(process.execPath, [uiPath], {
|
|
69
71
|
detached: true,
|
|
70
72
|
stdio: "ignore",
|
|
71
|
-
env: { ...process.env, PORT: String(PORT) },
|
|
73
|
+
env: { ...process.env, PORT: String(PORT), ENABLE_MCP: "1" },
|
|
72
74
|
});
|
|
73
75
|
child.unref();
|
|
74
76
|
}
|
|
@@ -159,6 +161,21 @@ async function httpInitialize() {
|
|
|
159
161
|
// Follow-up: the spec requires a notifications/initialized after initialize.
|
|
160
162
|
await httpRpc("notifications/initialized").catch(() => { });
|
|
161
163
|
}
|
|
164
|
+
// Periodically touch our /mcp session so the server's reaper doesn't prune it.
|
|
165
|
+
// Without this, the bridge opens a session on startup, proxies tool calls
|
|
166
|
+
// infrequently, and after 60s of idle the server wipes the session from its
|
|
167
|
+
// sessions[] map — which makes Telegram's "routed to X agents" ack say
|
|
168
|
+
// "no agents connected" even though the bridge is still alive and subscribed.
|
|
169
|
+
function startSessionKeepalive() {
|
|
170
|
+
setInterval(async () => {
|
|
171
|
+
try {
|
|
172
|
+
await httpRpc("tools/list").catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// best effort; next call will re-initialize via 404 retry path
|
|
176
|
+
}
|
|
177
|
+
}, 30_000);
|
|
178
|
+
}
|
|
162
179
|
// ── 3. Stdio MCP server — the thing Claude Code / Cursor attaches to ─────────
|
|
163
180
|
const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
164
181
|
// Declare the claude/channel capability so Claude Code knows to surface
|
|
@@ -168,10 +185,14 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
168
185
|
capabilities: {
|
|
169
186
|
experimental: { "claude/channel": {} },
|
|
170
187
|
},
|
|
171
|
-
instructions: "This is the stdio bridge for notify-mcp.
|
|
172
|
-
"messages
|
|
173
|
-
"
|
|
174
|
-
"
|
|
188
|
+
instructions: "This is the stdio bridge for notify-mcp. To receive unsolicited user " +
|
|
189
|
+
"messages in ANY MCP host (Claude Code, Copilot, Cursor), call `wait_for_inbox` " +
|
|
190
|
+
"— it long-polls up to 50s and returns new user messages as tool results. Call " +
|
|
191
|
+
"it whenever you finish a task or would otherwise wait, then again right after " +
|
|
192
|
+
"handling each batch, so messages keep arriving. (The Claude Code CLI launched " +
|
|
193
|
+
"with `--channels` ALSO delivers messages as synthetic turns automatically, but " +
|
|
194
|
+
"that path does NOT exist in the VSCode extension or Copilot — `wait_for_inbox` " +
|
|
195
|
+
"is the reliable lowest-common-denominator mechanism across every host.)\n\n" +
|
|
175
196
|
"ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
|
|
176
197
|
"just finished (success or failure), (b) you have a question or need a " +
|
|
177
198
|
"decision, (c) something important happened the user needs to know right " +
|
|
@@ -182,6 +203,15 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
182
203
|
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
183
204
|
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
184
205
|
"Say 'notif' or 'notification' if you need to refer to the act of notifying.\n\n" +
|
|
206
|
+
"🚨 500-CHAR LIMIT — CHUNK, NEVER TRUNCATE 🚨\n" +
|
|
207
|
+
"The `notify` tool rejects bodies > 500 chars with `MCP error -32602: too_big`. " +
|
|
208
|
+
"When you have more to say than fits in 500 chars, you MUST split into MULTIPLE " +
|
|
209
|
+
"notify calls — do NOT silently shorten the body. Procedure: (1) decide what " +
|
|
210
|
+
"the user MUST see (every fact, file path, line number, recommendation); (2) if " +
|
|
211
|
+
"the body exceeds 500 chars, split into N chunks numbered '(1/N) ...', '(2/N) ...', " +
|
|
212
|
+
"each ≤ 500 chars including the prefix; (3) send all N chunks in order via separate " +
|
|
213
|
+
"notify calls and echo all N IN FULL in chat. NEVER respond to a `too_big` error by " +
|
|
214
|
+
"shortening to a single chunk — that loses information the user explicitly needs.\n\n" +
|
|
185
215
|
"When the user asks you to remember a behavioral rule or change how you should act, " +
|
|
186
216
|
"call `update_instructions` with the full updated rules block. This writes to CLAUDE.md " +
|
|
187
217
|
"so the instructions persist across sessions and context compaction.",
|
|
@@ -200,7 +230,10 @@ async function proxyToolCall(name, args) {
|
|
|
200
230
|
}
|
|
201
231
|
return { content: [{ type: "text", text: JSON.stringify(result ?? {}) }] };
|
|
202
232
|
}
|
|
203
|
-
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured."
|
|
233
|
+
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
234
|
+
"MAX 500 CHARS PER MESSAGE. If you have more to say, split into multiple notify " +
|
|
235
|
+
"calls with '(1/N) ...', '(2/N) ...' prefixes — never silently shorten on " +
|
|
236
|
+
"`too_big` error; that loses information the user needs.", {
|
|
204
237
|
message: z.string().max(500),
|
|
205
238
|
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
|
206
239
|
}, async (args) => proxyToolCall("notify", args));
|
|
@@ -323,6 +356,7 @@ async function main() {
|
|
|
323
356
|
// Fire-and-forget: the stdio transport should be usable immediately; the
|
|
324
357
|
// push channel attaches as soon as the SSE handshake completes.
|
|
325
358
|
subscribeInbox().catch(err => stderr(`[bridge] inbox subscriber crashed: ${err instanceof Error ? err.message : String(err)}`));
|
|
359
|
+
startSessionKeepalive();
|
|
326
360
|
const transport = new StdioServerTransport();
|
|
327
361
|
await server.connect(transport);
|
|
328
362
|
stderr(`[bridge] stdio MCP bridge ready (tag=${SESSION_TAG ?? "none"}, port=${PORT})`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function computeDesktopOnlyMode(priority, policy, ctx) {
|
|
2
|
+
if (priority === "high") {
|
|
3
|
+
return { desktopOnly: false };
|
|
4
|
+
}
|
|
5
|
+
if (policy.dndActive) {
|
|
6
|
+
return { desktopOnly: false, suppressedReason: "dnd" };
|
|
7
|
+
}
|
|
8
|
+
if (ctx.uiActive || (policy.idleEnabled && !ctx.inTelegramConversation && ctx.idleSeconds >= 0 && ctx.idleSeconds < policy.idleThresholdSeconds)) {
|
|
9
|
+
if (policy.alwaysDesktopWhenActive) {
|
|
10
|
+
return { desktopOnly: true };
|
|
11
|
+
}
|
|
12
|
+
return { desktopOnly: false, suppressedReason: "idle" };
|
|
13
|
+
}
|
|
14
|
+
return { desktopOnly: false };
|
|
15
|
+
}
|
|
16
|
+
export async function sendWithRouting(options) {
|
|
17
|
+
const { message, priority, senders, policy, ctx, enableDesktop, enableTelegram, enableEmail, enableSms, enableNtfy, enableDiscord, enableSlack, enableTeams, } = options;
|
|
18
|
+
const mode = computeDesktopOnlyMode(priority, policy, ctx);
|
|
19
|
+
if (mode.suppressedReason === "dnd") {
|
|
20
|
+
return { delivered: [], errors: [], suppressedReason: "DND active" };
|
|
21
|
+
}
|
|
22
|
+
if (mode.suppressedReason === "idle") {
|
|
23
|
+
return { delivered: [], errors: [], suppressedReason: "Idle gated while active" };
|
|
24
|
+
}
|
|
25
|
+
const delivered = [];
|
|
26
|
+
const errors = [];
|
|
27
|
+
const desktopOnly = mode.desktopOnly;
|
|
28
|
+
const trySend = async (name, fn) => {
|
|
29
|
+
if (!fn)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
await fn();
|
|
33
|
+
delivered.push(name);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
errors.push(`${name}: ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if (priority !== "low") {
|
|
41
|
+
if (enableDesktop) {
|
|
42
|
+
await trySend("desktop", senders.desktop ? () => senders.desktop(message) : undefined);
|
|
43
|
+
}
|
|
44
|
+
if (!desktopOnly && enableTelegram) {
|
|
45
|
+
await trySend("telegram", senders.telegram ? () => senders.telegram(message) : undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!desktopOnly && enableEmail) {
|
|
49
|
+
await trySend("email", senders.email ? () => senders.email(message) : undefined);
|
|
50
|
+
}
|
|
51
|
+
if (!desktopOnly && enableNtfy) {
|
|
52
|
+
await trySend("ntfy", senders.ntfy ? () => senders.ntfy(message, priority) : undefined);
|
|
53
|
+
}
|
|
54
|
+
if (!desktopOnly && enableDiscord) {
|
|
55
|
+
await trySend("discord", senders.discord ? () => senders.discord(message, priority) : undefined);
|
|
56
|
+
}
|
|
57
|
+
if (!desktopOnly && enableSlack) {
|
|
58
|
+
await trySend("slack", senders.slack ? () => senders.slack(message, priority) : undefined);
|
|
59
|
+
}
|
|
60
|
+
if (!desktopOnly && enableTeams) {
|
|
61
|
+
await trySend("teams", senders.teams ? () => senders.teams(message, priority) : undefined);
|
|
62
|
+
}
|
|
63
|
+
if (priority === "high" && enableSms) {
|
|
64
|
+
await trySend("sms", senders.sms ? () => senders.sms(message) : undefined);
|
|
65
|
+
}
|
|
66
|
+
return { delivered, errors };
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomUUID } from "crypto";
|
|
2
|
+
import { randomUUID, createHmac, timingSafeEqual } from "crypto";
|
|
3
3
|
import express from "express";
|
|
4
4
|
import { google } from "googleapis";
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
@@ -11,16 +11,20 @@ import open from "open";
|
|
|
11
11
|
import notifier from "node-notifier";
|
|
12
12
|
import nodemailer from "nodemailer";
|
|
13
13
|
import twilio from "twilio";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { sendWithRouting } from "./messaging/notificationEngine.js";
|
|
16
|
+
import { z } from "zod";
|
|
14
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
18
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
|
-
import { z } from "zod";
|
|
17
|
-
import { tmpdir } from "os";
|
|
18
19
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3737;
|
|
19
20
|
const REDIRECT_URI = `http://localhost:${PORT}/auth/google/callback`;
|
|
20
21
|
const PUBLIC_DIR = join(fileURLToPath(new URL("../../ui/public", import.meta.url)));
|
|
21
22
|
const CONFIG_DIR = join(homedir(), ".notify-mcp");
|
|
22
23
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
23
24
|
const ADC_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json");
|
|
25
|
+
const AGENT_API_KEY = (process.env.NOTIFY_AGENT_KEY ?? "").trim();
|
|
26
|
+
const SLACK_SIGNING_SECRET = (process.env.SLACK_SIGNING_SECRET ?? "").trim();
|
|
27
|
+
const ENABLE_MCP = (process.env.ENABLE_MCP ?? "").trim() === "1";
|
|
24
28
|
function defaultConfig() {
|
|
25
29
|
return {
|
|
26
30
|
desktop: { enabled: false, sound: true },
|
|
@@ -142,8 +146,51 @@ function mergePreservingSecrets(existing, update) {
|
|
|
142
146
|
}
|
|
143
147
|
const app = express();
|
|
144
148
|
app.use((req, _res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} UA:${req.headers['user-agent']?.slice(0, 60)}`); next(); });
|
|
145
|
-
app.use(express.json(
|
|
149
|
+
app.use(express.json({
|
|
150
|
+
verify: (req, _res, buf) => {
|
|
151
|
+
req.rawBody = Buffer.from(buf);
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
app.use(express.urlencoded({
|
|
155
|
+
extended: true,
|
|
156
|
+
verify: (req, _res, buf) => {
|
|
157
|
+
const r = req;
|
|
158
|
+
if (!r.rawBody)
|
|
159
|
+
r.rawBody = Buffer.from(buf);
|
|
160
|
+
},
|
|
161
|
+
}));
|
|
146
162
|
app.use(express.static(PUBLIC_DIR));
|
|
163
|
+
function requireAgentAuth(req, res) {
|
|
164
|
+
if (!AGENT_API_KEY)
|
|
165
|
+
return true;
|
|
166
|
+
const got = String(req.headers["x-notify-key"] ?? "").trim();
|
|
167
|
+
if (!got || got !== AGENT_API_KEY) {
|
|
168
|
+
res.status(401).json({ error: "unauthorized" });
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
function verifySlackSignature(req) {
|
|
174
|
+
if (!SLACK_SIGNING_SECRET)
|
|
175
|
+
return true;
|
|
176
|
+
const sig = String(req.headers["x-slack-signature"] ?? "");
|
|
177
|
+
const ts = String(req.headers["x-slack-request-timestamp"] ?? "");
|
|
178
|
+
if (!sig || !ts)
|
|
179
|
+
return false;
|
|
180
|
+
const tsNum = parseInt(ts, 10);
|
|
181
|
+
if (!Number.isFinite(tsNum))
|
|
182
|
+
return false;
|
|
183
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - tsNum) > 300)
|
|
184
|
+
return false;
|
|
185
|
+
const rawBody = (req.rawBody ?? Buffer.from("{}")).toString("utf8");
|
|
186
|
+
const base = `v0:${ts}:${rawBody}`;
|
|
187
|
+
const expected = `v0=${createHmac("sha256", SLACK_SIGNING_SECRET).update(base).digest("hex")}`;
|
|
188
|
+
const a = Buffer.from(expected, "utf8");
|
|
189
|
+
const b = Buffer.from(sig, "utf8");
|
|
190
|
+
if (a.length !== b.length)
|
|
191
|
+
return false;
|
|
192
|
+
return timingSafeEqual(a, b);
|
|
193
|
+
}
|
|
147
194
|
const ntfySubscribers = new Map();
|
|
148
195
|
function ntfyFanout(topic, message, title, priority, tags) {
|
|
149
196
|
const subs = ntfySubscribers.get(topic);
|
|
@@ -349,7 +396,8 @@ app.post("/api/test/tts", async (req, res) => {
|
|
|
349
396
|
const voice = (typeof req.body?.voice === "string" && req.body.voice) ||
|
|
350
397
|
cfg.desktop?.ttsVoice ||
|
|
351
398
|
"en-US-AndrewMultilingualNeural";
|
|
352
|
-
|
|
399
|
+
const text = (typeof req.body?.text === "string" && req.body.text.trim()) || "Notification from Claude. This is a voice test.";
|
|
400
|
+
await speakText(text, voice);
|
|
353
401
|
res.json({ ok: true, message: `TTS played (${voice})` });
|
|
354
402
|
}
|
|
355
403
|
catch (err) {
|
|
@@ -776,33 +824,18 @@ function log(direction, channel, text, client) {
|
|
|
776
824
|
}
|
|
777
825
|
}
|
|
778
826
|
app.get("/api/sessions", (_req, res) => {
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
connectedAt: s.connectedAt,
|
|
787
|
-
lastSeen: s.lastSeen,
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
const list = [...inboxStreamClients].map((c, i) => ({
|
|
829
|
+
clientId: `sse-${i + 1}`,
|
|
830
|
+
tag: c.tag,
|
|
831
|
+
transport: "sse",
|
|
832
|
+
connectedAt: now,
|
|
833
|
+
lastSeen: now,
|
|
788
834
|
}));
|
|
789
835
|
res.json({ sessions: list });
|
|
790
836
|
});
|
|
791
|
-
app.delete("/api/sessions/:clientId", (
|
|
792
|
-
|
|
793
|
-
const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
|
|
794
|
-
if (!entry) {
|
|
795
|
-
res.status(404).json({ error: "not found" });
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
const [sessionId] = entry;
|
|
799
|
-
try {
|
|
800
|
-
httpTransports[sessionId]?.close();
|
|
801
|
-
}
|
|
802
|
-
catch { /* ignore */ }
|
|
803
|
-
delete httpTransports[sessionId];
|
|
804
|
-
delete sessions[sessionId];
|
|
805
|
-
res.json({ ok: true });
|
|
837
|
+
app.delete("/api/sessions/:clientId", (_req, res) => {
|
|
838
|
+
res.status(410).json({ error: "session_disconnect_unsupported", message: "HTTP mode does not support remote disconnect." });
|
|
806
839
|
});
|
|
807
840
|
app.get("/api/logs", (req, res) => {
|
|
808
841
|
res.setHeader("Content-Type", "text/event-stream");
|
|
@@ -817,198 +850,149 @@ app.get("/api/logs", (req, res) => {
|
|
|
817
850
|
// ── Notification sender ───────────────────────────────────────────────────────
|
|
818
851
|
async function sendNotification(message, priority, client) {
|
|
819
852
|
const cfg = loadConfig();
|
|
820
|
-
const results = [];
|
|
821
|
-
const errors = [];
|
|
822
|
-
// DND check — priority=high always bypasses; anything else gets dropped during quiet hours.
|
|
823
|
-
// Email still goes through on "low" anyway (historical behavior: low=email-only).
|
|
824
|
-
if (priority !== "high" && isDndActive(cfg)) {
|
|
825
|
-
log("·", "dnd", `suppressed ${priority} notif (DND active)`, client);
|
|
826
|
-
return "DND active — notif suppressed (priority=high would still send)";
|
|
827
|
-
}
|
|
828
|
-
// Idle gating — when the user is actively at the keyboard, suppress *remote*
|
|
829
|
-
// channels (Telegram/SMS/email) for non-high priority. By default we still
|
|
830
|
-
// play the desktop sound+banner so the user knows *something* happened —
|
|
831
|
-
// they may have multiple agents running and this is a cheap local signal
|
|
832
|
-
// that doesn't blast their phone. Disable via idle.alwaysDesktopWhenActive=false.
|
|
833
|
-
// priority=high always bypasses idle entirely.
|
|
834
|
-
// Conversation bypass: if the user just messaged us from Telegram (within
|
|
835
|
-
// the TTL), they clearly want a reply over that channel, so skip idle gating.
|
|
836
853
|
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
// SystemSounds.Asterisk plays through the actual sound card via
|
|
885
|
-
// the Windows notification sound, audible on every machine.
|
|
886
|
-
spawn("powershell", [
|
|
887
|
-
"-NoProfile", "-Command",
|
|
888
|
-
"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
|
|
889
|
-
], { windowsHide: true, stdio: "ignore" });
|
|
890
|
-
}
|
|
891
|
-
const soundOpt = wantSound && process.platform !== "win32";
|
|
892
|
-
if (cfg.desktop?.tts) {
|
|
893
|
-
const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
|
|
894
|
-
speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
|
|
895
|
-
}
|
|
896
|
-
await send("desktop", () => new Promise((res, rej) => notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => err ? rej(err) : res())));
|
|
897
|
-
}
|
|
898
|
-
if (!desktopOnlyMode && cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId) {
|
|
899
|
-
await send("telegram", async () => {
|
|
854
|
+
const idleSecs = getOsIdleSeconds();
|
|
855
|
+
const result = await sendWithRouting({
|
|
856
|
+
message,
|
|
857
|
+
priority,
|
|
858
|
+
policy: {
|
|
859
|
+
idleEnabled: cfg.idle?.enabled !== false,
|
|
860
|
+
idleThresholdSeconds: cfg.idle?.thresholdSeconds ?? 120,
|
|
861
|
+
alwaysDesktopWhenActive: cfg.idle?.alwaysDesktopWhenActive !== false,
|
|
862
|
+
dndActive: isDndActive(cfg),
|
|
863
|
+
},
|
|
864
|
+
ctx: {
|
|
865
|
+
inTelegramConversation: inTelegramConvo,
|
|
866
|
+
uiActive: isUiActivelyOpen(),
|
|
867
|
+
idleSeconds: idleSecs,
|
|
868
|
+
},
|
|
869
|
+
enableDesktop: !!cfg.desktop?.enabled,
|
|
870
|
+
enableTelegram: !!(cfg.telegram?.enabled && cfg.telegram.token && cfg.telegram.chatId),
|
|
871
|
+
enableEmail: !!(cfg.email?.enabled && cfg.email.to),
|
|
872
|
+
enableSms: !!(cfg.sms?.enabled && cfg.sms.accountSid && cfg.sms.authToken && cfg.sms.from && cfg.sms.to),
|
|
873
|
+
enableNtfy: !!(cfg.ntfy?.enabled && cfg.ntfy.topic),
|
|
874
|
+
enableDiscord: !!(cfg.discord?.enabled && cfg.discord.webhookUrl),
|
|
875
|
+
enableSlack: !!(cfg.slack?.enabled && cfg.slack.webhookUrl),
|
|
876
|
+
enableTeams: !!(cfg.teams?.enabled && cfg.teams.webhookUrl),
|
|
877
|
+
senders: {
|
|
878
|
+
desktop: async () => {
|
|
879
|
+
const wantSound = cfg.desktop?.sound !== false;
|
|
880
|
+
if (wantSound && process.platform === "win32") {
|
|
881
|
+
spawn("powershell", [
|
|
882
|
+
"-NoProfile", "-Command",
|
|
883
|
+
"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SystemSounds]::Asterisk.Play(); Start-Sleep -Milliseconds 600",
|
|
884
|
+
], { windowsHide: true, stdio: "ignore" });
|
|
885
|
+
}
|
|
886
|
+
const soundOpt = wantSound && process.platform !== "win32";
|
|
887
|
+
if (cfg.desktop?.tts) {
|
|
888
|
+
const voice = cfg.desktop?.ttsVoice ?? "en-US-AndrewMultilingualNeural";
|
|
889
|
+
speakText(message, voice).catch((err) => log("→", "tts", `ERROR: ${err instanceof Error ? err.message : String(err)}`, client));
|
|
890
|
+
}
|
|
891
|
+
await new Promise((resolve, reject) => {
|
|
892
|
+
notifier.notify({ title: "Claude Notify", message, sound: soundOpt }, (err) => {
|
|
893
|
+
if (err)
|
|
894
|
+
reject(err);
|
|
895
|
+
else
|
|
896
|
+
resolve();
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
telegram: async () => {
|
|
900
901
|
const body = { chat_id: cfg.telegram.chatId, text: message };
|
|
901
902
|
if (lastUserMessageId)
|
|
902
903
|
body.reply_to_message_id = lastUserMessageId;
|
|
903
904
|
const r = await fetch(`https://api.telegram.org/bot${cfg.telegram.token}/sendMessage`, {
|
|
904
|
-
method: "POST",
|
|
905
|
+
method: "POST",
|
|
906
|
+
headers: { "Content-Type": "application/json" },
|
|
905
907
|
body: JSON.stringify(body),
|
|
906
908
|
});
|
|
907
909
|
if (!r.ok)
|
|
908
910
|
throw new Error(await r.text());
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
911
|
+
},
|
|
912
|
+
sms: async () => {
|
|
913
|
+
const smsClient = twilio(cfg.sms.accountSid, cfg.sms.authToken);
|
|
914
|
+
await smsClient.messages.create({ body: message, from: cfg.sms.from, to: cfg.sms.to });
|
|
915
|
+
},
|
|
916
|
+
email: async () => {
|
|
917
|
+
const email = cfg.email;
|
|
918
|
+
let transport;
|
|
919
|
+
if (email.refreshToken && email.clientId && email.clientSecret) {
|
|
920
|
+
transport = nodemailer.createTransport({
|
|
921
|
+
service: "gmail",
|
|
922
|
+
auth: {
|
|
923
|
+
type: "OAuth2",
|
|
924
|
+
user: email.connectedEmail ?? email.to,
|
|
925
|
+
clientId: email.clientId,
|
|
926
|
+
clientSecret: email.clientSecret,
|
|
927
|
+
refreshToken: email.refreshToken,
|
|
928
|
+
accessToken: email.accessToken,
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
else if (email.host && email.user && email.pass) {
|
|
933
|
+
transport = nodemailer.createTransport({
|
|
934
|
+
host: email.host,
|
|
935
|
+
port: email.port ?? 587,
|
|
936
|
+
secure: email.secure ?? false,
|
|
937
|
+
auth: { user: email.user, pass: email.pass },
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
await transport.sendMail({
|
|
944
|
+
from: email.connectedEmail ?? email.user ?? email.to,
|
|
945
|
+
to: email.to,
|
|
946
|
+
subject: "Claude Notify",
|
|
947
|
+
text: message,
|
|
936
948
|
});
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
return;
|
|
940
|
-
await transport.sendMail({ from: email.connectedEmail ?? email.user ?? email.to,
|
|
941
|
-
to: email.to, subject: "Claude Notify", text: message });
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
// ntfy (built-in server — direct fanout, no external fetch)
|
|
945
|
-
if (!desktopOnlyMode) {
|
|
946
|
-
const ntfy = cfg.ntfy ?? {};
|
|
947
|
-
if (ntfy.enabled && ntfy.topic) {
|
|
948
|
-
await send("ntfy", async () => {
|
|
949
|
+
},
|
|
950
|
+
ntfy: async (_text, prio) => {
|
|
949
951
|
const priorityMap = { low: 2, normal: 3, high: 5 };
|
|
950
|
-
const tags =
|
|
951
|
-
const subs = ntfySubscribers.get(ntfy.topic)?.size ?? 0;
|
|
952
|
+
const tags = prio === "high" ? "rotating_light" : "bell";
|
|
953
|
+
const subs = ntfySubscribers.get(cfg.ntfy.topic)?.size ?? 0;
|
|
952
954
|
if (subs === 0)
|
|
953
|
-
throw new Error(`ntfy: no subscribers on topic '${ntfy.topic}'
|
|
954
|
-
ntfyFanout(ntfy.topic, message, "Claude Notify", priorityMap[
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
}
|
|
958
|
-
// Discord
|
|
959
|
-
if (!desktopOnlyMode) {
|
|
960
|
-
const dc = cfg.discord ?? {};
|
|
961
|
-
if (dc.enabled && dc.webhookUrl) {
|
|
962
|
-
await send("discord", async () => {
|
|
955
|
+
throw new Error(`ntfy: no subscribers on topic '${cfg.ntfy.topic}'`);
|
|
956
|
+
ntfyFanout(cfg.ntfy.topic, message, "Claude Notify", priorityMap[prio] ?? 3, tags);
|
|
957
|
+
},
|
|
958
|
+
discord: async (_text, prio) => {
|
|
963
959
|
const colorMap = { low: 0x6b7280, normal: 0x7c6dfa, high: 0xef4444 };
|
|
964
|
-
const r = await fetch(
|
|
960
|
+
const r = await fetch(cfg.discord.webhookUrl, {
|
|
965
961
|
method: "POST",
|
|
966
962
|
headers: { "Content-Type": "application/json" },
|
|
967
963
|
body: JSON.stringify({
|
|
968
|
-
username:
|
|
964
|
+
username: cfg.discord.username ?? "Claude Notify",
|
|
969
965
|
embeds: [{
|
|
970
966
|
title: "Claude Notify",
|
|
971
967
|
description: message,
|
|
972
|
-
color: colorMap[
|
|
968
|
+
color: colorMap[prio] ?? colorMap.normal,
|
|
973
969
|
timestamp: new Date().toISOString(),
|
|
974
970
|
}],
|
|
975
971
|
}),
|
|
976
972
|
});
|
|
977
973
|
if (!r.ok)
|
|
978
974
|
throw new Error(`Discord ${r.status}: ${await r.text()}`);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
}
|
|
982
|
-
// Slack
|
|
983
|
-
if (!desktopOnlyMode) {
|
|
984
|
-
const sl = cfg.slack ?? {};
|
|
985
|
-
if (sl.enabled && sl.webhookUrl) {
|
|
986
|
-
await send("slack", async () => {
|
|
975
|
+
},
|
|
976
|
+
slack: async (_text, prio) => {
|
|
987
977
|
const emojiMap = { low: "ℹ️", normal: "🔔", high: "🚨" };
|
|
988
|
-
const emoji = emojiMap[
|
|
989
|
-
const r = await fetch(
|
|
978
|
+
const emoji = emojiMap[prio] ?? emojiMap.normal;
|
|
979
|
+
const r = await fetch(cfg.slack.webhookUrl, {
|
|
990
980
|
method: "POST",
|
|
991
981
|
headers: { "Content-Type": "application/json" },
|
|
992
982
|
body: JSON.stringify({
|
|
993
983
|
text: `${emoji} *Claude Notify*`,
|
|
994
984
|
blocks: [
|
|
995
985
|
{ type: "section", text: { type: "mrkdwn", text: `${emoji} *Claude Notify*\n${message}` } },
|
|
996
|
-
{ type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${
|
|
986
|
+
{ type: "context", elements: [{ type: "mrkdwn", text: `Priority: ${prio}` }] },
|
|
997
987
|
],
|
|
998
988
|
}),
|
|
999
989
|
});
|
|
1000
990
|
if (!r.ok)
|
|
1001
991
|
throw new Error(`Slack ${r.status}: ${await r.text()}`);
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
// Teams
|
|
1006
|
-
if (!desktopOnlyMode) {
|
|
1007
|
-
const tm = cfg.teams ?? {};
|
|
1008
|
-
if (tm.enabled && tm.webhookUrl) {
|
|
1009
|
-
await send("teams", async () => {
|
|
992
|
+
},
|
|
993
|
+
teams: async (_text, prio) => {
|
|
1010
994
|
const colorMap = { low: "Default", normal: "Accent", high: "Attention" };
|
|
1011
|
-
const r = await fetch(
|
|
995
|
+
const r = await fetch(cfg.teams.webhookUrl, {
|
|
1012
996
|
method: "POST",
|
|
1013
997
|
headers: { "Content-Type": "application/json" },
|
|
1014
998
|
body: JSON.stringify({
|
|
@@ -1018,11 +1002,12 @@ async function sendNotification(message, priority, client) {
|
|
|
1018
1002
|
contentUrl: null,
|
|
1019
1003
|
content: {
|
|
1020
1004
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
1021
|
-
type: "AdaptiveCard",
|
|
1005
|
+
type: "AdaptiveCard",
|
|
1006
|
+
version: "1.2",
|
|
1022
1007
|
body: [
|
|
1023
|
-
{ type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[
|
|
1008
|
+
{ type: "TextBlock", size: "Medium", weight: "Bolder", text: "Claude Notify", color: colorMap[prio] ?? "Default" },
|
|
1024
1009
|
{ type: "TextBlock", text: message, wrap: true },
|
|
1025
|
-
{ type: "TextBlock", text: `Priority: ${
|
|
1010
|
+
{ type: "TextBlock", text: `Priority: ${prio}`, isSubtle: true, size: "Small" },
|
|
1026
1011
|
],
|
|
1027
1012
|
},
|
|
1028
1013
|
}],
|
|
@@ -1030,12 +1015,22 @@ async function sendNotification(message, priority, client) {
|
|
|
1030
1015
|
});
|
|
1031
1016
|
if (!r.ok)
|
|
1032
1017
|
throw new Error(`Teams ${r.status}: ${await r.text()}`);
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
if (result.suppressedReason) {
|
|
1022
|
+
log("·", "notify", `suppressed (${result.suppressedReason})`, client);
|
|
1023
|
+
return result.suppressedReason;
|
|
1024
|
+
}
|
|
1025
|
+
for (const delivered of result.delivered) {
|
|
1026
|
+
log("→", delivered, message, client);
|
|
1027
|
+
}
|
|
1028
|
+
for (const err of result.errors) {
|
|
1029
|
+
log("→", "notify", `ERROR: ${err}`, client);
|
|
1035
1030
|
}
|
|
1036
1031
|
return [
|
|
1037
|
-
|
|
1038
|
-
errors.length ? `Errors: ${errors.join("; ")}` : null,
|
|
1032
|
+
result.delivered.length ? `Sent via: ${result.delivered.join(", ")}` : null,
|
|
1033
|
+
result.errors.length ? `Errors: ${result.errors.join("; ")}` : null,
|
|
1039
1034
|
].filter(Boolean).join(" | ") || "No channels delivered";
|
|
1040
1035
|
}
|
|
1041
1036
|
// ── OS idle-time (cross-platform) ─────────────────────────────────────────────
|
|
@@ -1173,8 +1168,11 @@ function writeInboxDrop(entry) {
|
|
|
1173
1168
|
const header = `# Unsolicited user message\n\n` +
|
|
1174
1169
|
`- Time: ${entry.ts}\n` +
|
|
1175
1170
|
(entry.tag ? `- Tag: @${entry.tag}\n` : "") +
|
|
1176
|
-
`- Origin: user (out-of-band)\n\n`;
|
|
1177
|
-
|
|
1171
|
+
`- Origin: ${entry.origin ?? "user"} (out-of-band)\n\n`;
|
|
1172
|
+
const replyHint = entry.origin === "slack"
|
|
1173
|
+
? `\n---\n↩ This arrived over the Slack bus — the user is WAITING in the channel. Reply there ASAP when done:\n\`\`\`\ncurl -s -X POST http://localhost:${PORT}/api/agent/slack/reply -H 'Content-Type: application/json' -d '{"text":"YOUR ANSWER","tag":"${entry.tag ?? ""}"}'\n\`\`\`\n`
|
|
1174
|
+
: "";
|
|
1175
|
+
writeFileSync(path, header + entry.text + "\n" + replyHint);
|
|
1178
1176
|
}
|
|
1179
1177
|
catch (err) {
|
|
1180
1178
|
log("·", "inbox-drop", `write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1225,6 +1223,22 @@ function broadcastInbox(entry) {
|
|
|
1225
1223
|
}
|
|
1226
1224
|
return delivered;
|
|
1227
1225
|
}
|
|
1226
|
+
function ingestInboxEntry(entry, source) {
|
|
1227
|
+
const waiters = takeWaitersFor(entry.tag);
|
|
1228
|
+
if (waiters.length > 0) {
|
|
1229
|
+
for (const w of waiters) {
|
|
1230
|
+
clearTimeout(w.timer);
|
|
1231
|
+
w.resolve([entry]);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
else {
|
|
1235
|
+
inboxQueue.push(entry);
|
|
1236
|
+
writeInboxDrop(entry);
|
|
1237
|
+
}
|
|
1238
|
+
const sse = broadcastInbox(entry);
|
|
1239
|
+
log("·", "inbox", `${source}: ${entry.text} (sse=${sse}, waiters=${waiters.length})`, entry.tag);
|
|
1240
|
+
return { waiters: waiters.length, sse };
|
|
1241
|
+
}
|
|
1228
1242
|
// Test-only: inject a fake inbox entry exactly as the Telegram listener would.
|
|
1229
1243
|
// Gated behind NOTIFY_MCP_TEST_ENDPOINTS=1 so it's never exposed in a normal
|
|
1230
1244
|
// production run. Used by the test suite to drive wait_for_inbox wake-up and
|
|
@@ -1238,20 +1252,8 @@ if (process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1") {
|
|
|
1238
1252
|
return;
|
|
1239
1253
|
}
|
|
1240
1254
|
const entry = { text, ts: new Date().toISOString(), tag };
|
|
1241
|
-
const
|
|
1242
|
-
|
|
1243
|
-
for (const w of waiters) {
|
|
1244
|
-
clearTimeout(w.timer);
|
|
1245
|
-
w.resolve([entry]);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
else {
|
|
1249
|
-
inboxQueue.push(entry);
|
|
1250
|
-
}
|
|
1251
|
-
const sse = broadcastInbox(entry);
|
|
1252
|
-
writeInboxDrop(entry);
|
|
1253
|
-
log("·", "test-inject", `${text} (waiters=${waiters.length}, sse=${sse})`, tag);
|
|
1254
|
-
res.json({ injected: true, waiters: waiters.length, sse });
|
|
1255
|
+
const out = ingestInboxEntry(entry, "test-inject");
|
|
1256
|
+
res.json({ injected: true, waiters: out.waiters, sse: out.sse });
|
|
1255
1257
|
});
|
|
1256
1258
|
log("·", "test", "NOTIFY_MCP_TEST_ENDPOINTS=1 — /__test__/inject-inbox enabled");
|
|
1257
1259
|
}
|
|
@@ -1278,6 +1280,206 @@ app.get("/api/inbox/stream", (req, res) => {
|
|
|
1278
1280
|
inboxStreamClients.delete(client);
|
|
1279
1281
|
});
|
|
1280
1282
|
});
|
|
1283
|
+
// ── Non-MCP automation API ───────────────────────────────────────────────────
|
|
1284
|
+
// These endpoints let an agent script use plain HTTP (no MCP transport) for
|
|
1285
|
+
// notify + unsolicited inbox handling.
|
|
1286
|
+
app.post("/api/agent/notify", async (req, res) => {
|
|
1287
|
+
if (!requireAgentAuth(req, res))
|
|
1288
|
+
return;
|
|
1289
|
+
const message = String(req.body?.message ?? "").trim();
|
|
1290
|
+
const priority = String(req.body?.priority ?? "normal");
|
|
1291
|
+
if (!message) {
|
|
1292
|
+
res.status(400).json({ error: "message required" });
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (!["low", "normal", "high"].includes(priority)) {
|
|
1296
|
+
res.status(400).json({ error: "invalid priority" });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
try {
|
|
1300
|
+
const out = await sendNotification(message, priority, "agent-http");
|
|
1301
|
+
res.json({ ok: true, result: out });
|
|
1302
|
+
}
|
|
1303
|
+
catch (err) {
|
|
1304
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
app.get("/api/agent/inbox/poll", (req, res) => {
|
|
1308
|
+
if (!requireAgentAuth(req, res))
|
|
1309
|
+
return;
|
|
1310
|
+
const tag = typeof req.query.tag === "string" ? req.query.tag.toLowerCase() : undefined;
|
|
1311
|
+
const messages = drainInboxFor(tag);
|
|
1312
|
+
res.json({ ok: true, messages });
|
|
1313
|
+
});
|
|
1314
|
+
app.get("/api/agent/inbox/wait", async (req, res) => {
|
|
1315
|
+
if (!requireAgentAuth(req, res))
|
|
1316
|
+
return;
|
|
1317
|
+
const tag = typeof req.query.tag === "string" ? req.query.tag.toLowerCase() : undefined;
|
|
1318
|
+
const timeoutSecondsRaw = parseInt(String(req.query.timeout_seconds ?? "50"), 10);
|
|
1319
|
+
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) ? Math.max(5, Math.min(55, timeoutSecondsRaw)) : 50;
|
|
1320
|
+
const queued = drainInboxFor(tag);
|
|
1321
|
+
if (queued.length > 0) {
|
|
1322
|
+
res.json({ ok: true, messages: queued, empty: false });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const token = randomUUID();
|
|
1326
|
+
const entries = await new Promise((resolve) => {
|
|
1327
|
+
const timer = setTimeout(() => {
|
|
1328
|
+
inboxWaiters.delete(token);
|
|
1329
|
+
resolve([]);
|
|
1330
|
+
}, timeoutSeconds * 1000);
|
|
1331
|
+
inboxWaiters.set(token, { resolve, timer, tag });
|
|
1332
|
+
});
|
|
1333
|
+
res.json({ ok: true, messages: entries, empty: entries.length === 0 });
|
|
1334
|
+
});
|
|
1335
|
+
app.post("/api/agent/inbox/inject", (req, res) => {
|
|
1336
|
+
if (!requireAgentAuth(req, res))
|
|
1337
|
+
return;
|
|
1338
|
+
const text = String(req.body?.text ?? "").trim();
|
|
1339
|
+
if (!text) {
|
|
1340
|
+
res.status(400).json({ error: "text required" });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
1344
|
+
const entry = { text, ts: new Date().toISOString(), tag };
|
|
1345
|
+
const out = ingestInboxEntry(entry, "agent-inject");
|
|
1346
|
+
res.json({ ok: true, waiters: out.waiters, sse: out.sse });
|
|
1347
|
+
});
|
|
1348
|
+
app.post("/api/agent/slack/reply", (req, res) => {
|
|
1349
|
+
if (!requireAgentAuth(req, res))
|
|
1350
|
+
return;
|
|
1351
|
+
const text = String(req.body?.text ?? "").trim();
|
|
1352
|
+
if (!text) {
|
|
1353
|
+
res.status(400).json({ error: "text required" });
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
1357
|
+
slackPost(tag ? `[@${tag}] ${text}` : text);
|
|
1358
|
+
log("→", "slack:reply", text, tag);
|
|
1359
|
+
res.json({ ok: true });
|
|
1360
|
+
});
|
|
1361
|
+
// Interactive-session busy/idle, reported by that session's hooks (busy on
|
|
1362
|
+
// UserPromptSubmit/PreToolUse/PostToolUse, idle on Stop). Lets the bus tell the
|
|
1363
|
+
// user "Claude is busy" + a rough ETA when a request lands on a busy prompt.
|
|
1364
|
+
const sessionBusy = {};
|
|
1365
|
+
const busyDurations = [];
|
|
1366
|
+
function setSessionState(tag, busy) {
|
|
1367
|
+
const prev = sessionBusy[tag];
|
|
1368
|
+
if (busy) {
|
|
1369
|
+
if (!prev?.busy)
|
|
1370
|
+
sessionBusy[tag] = { busy: true, since: Date.now() };
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
if (prev?.busy) {
|
|
1374
|
+
busyDurations.push(Date.now() - prev.since);
|
|
1375
|
+
if (busyDurations.length > 20)
|
|
1376
|
+
busyDurations.shift();
|
|
1377
|
+
}
|
|
1378
|
+
sessionBusy[tag] = { busy: false, since: 0 };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
function busyEtaSecs() {
|
|
1382
|
+
if (!busyDurations.length)
|
|
1383
|
+
return null;
|
|
1384
|
+
return Math.round(busyDurations.reduce((a, b) => a + b, 0) / busyDurations.length / 1000);
|
|
1385
|
+
}
|
|
1386
|
+
function sessionBusyNote(tag) {
|
|
1387
|
+
const s = sessionBusy[tag];
|
|
1388
|
+
if (!s?.busy)
|
|
1389
|
+
return "";
|
|
1390
|
+
const secs = Math.round((Date.now() - s.since) / 1000);
|
|
1391
|
+
const eta = busyEtaSecs();
|
|
1392
|
+
return `🔧 Claude @${tag} is busy right now (working ${secs}s) — your request is queued and runs the moment the prompt is free.${eta ? ` Turns here usually finish in ~${eta}s.` : ""}`;
|
|
1393
|
+
}
|
|
1394
|
+
app.post("/api/session/state", (req, res) => {
|
|
1395
|
+
const tag = req.body?.tag ? String(req.body.tag).toLowerCase() : undefined;
|
|
1396
|
+
if (!tag) {
|
|
1397
|
+
res.status(400).json({ error: "tag required" });
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
setSessionState(tag, req.body?.busy === true || req.body?.busy === "true");
|
|
1401
|
+
res.json({ ok: true, busy: sessionBusy[tag]?.busy ?? false });
|
|
1402
|
+
});
|
|
1403
|
+
// Slack Events API (inbound). Requires configuring your Slack app with an
|
|
1404
|
+
// Event Request URL: POST /api/slack/events.
|
|
1405
|
+
app.post("/api/slack/events", (req, res) => {
|
|
1406
|
+
let body = req.body ?? {};
|
|
1407
|
+
if (body?.payload && typeof body.payload === "string") {
|
|
1408
|
+
try {
|
|
1409
|
+
body = JSON.parse(body.payload);
|
|
1410
|
+
}
|
|
1411
|
+
catch {
|
|
1412
|
+
// keep original body if payload isn't valid JSON
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if ((!body || Object.keys(body).length === 0) && req.rawBody) {
|
|
1416
|
+
const raw = (req.rawBody ?? Buffer.from("{}")).toString("utf8");
|
|
1417
|
+
try {
|
|
1418
|
+
body = JSON.parse(raw);
|
|
1419
|
+
}
|
|
1420
|
+
catch {
|
|
1421
|
+
body = req.body ?? {};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
// Slack URL verification can arrive before full event wiring, and some
|
|
1425
|
+
// intermediaries/proxies send this as form data. Respond immediately when
|
|
1426
|
+
// a challenge value is present so the URL can be saved.
|
|
1427
|
+
const challenge = body?.challenge ?? (typeof req.query.challenge === "string" ? req.query.challenge : undefined);
|
|
1428
|
+
if (challenge) {
|
|
1429
|
+
log("·", "slack", "url_verification challenge received");
|
|
1430
|
+
res.status(200).json({ challenge });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (!verifySlackSignature(req)) {
|
|
1434
|
+
log("·", "slack", "rejected: bad signature");
|
|
1435
|
+
res.status(401).json({ error: "bad slack signature" });
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const envelopeType = String(body?.type ?? "unknown");
|
|
1439
|
+
const event = body?.event ?? {};
|
|
1440
|
+
const eventType = String(event?.type ?? "none");
|
|
1441
|
+
const subtype = typeof event?.subtype === "string" ? event.subtype : "";
|
|
1442
|
+
const channel = String(event?.channel ?? body?.channel_id ?? "none");
|
|
1443
|
+
log("·", "slack", `event: envelope=${envelopeType}, type=${eventType}, subtype=${subtype || "none"}, channel=${channel}`);
|
|
1444
|
+
if (body.type !== "event_callback") {
|
|
1445
|
+
res.json({ ok: true, ignored: true, reason: `unsupported envelope type: ${envelopeType}` });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
const ignoreReasons = [];
|
|
1449
|
+
if (event.type !== "message")
|
|
1450
|
+
ignoreReasons.push(`event.type=${eventType}`);
|
|
1451
|
+
if (subtype)
|
|
1452
|
+
ignoreReasons.push(`subtype=${subtype}`);
|
|
1453
|
+
if (event.bot_id)
|
|
1454
|
+
ignoreReasons.push("bot message");
|
|
1455
|
+
if (!event.text)
|
|
1456
|
+
ignoreReasons.push("missing text");
|
|
1457
|
+
if (event.type !== "message" || subtype || event.bot_id || !event.text) {
|
|
1458
|
+
const reason = ignoreReasons.join(", ") || "filtered";
|
|
1459
|
+
log("·", "slack", `ignored: ${reason}`);
|
|
1460
|
+
res.json({ ok: true, ignored: true, reason });
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const parsed = parseTag(String(event.text));
|
|
1464
|
+
const candidate = [...pendingAsks.entries()].find(([, p]) => (parsed.tag ? p.tag === parsed.tag : true));
|
|
1465
|
+
if (candidate) {
|
|
1466
|
+
const [id, pending] = candidate;
|
|
1467
|
+
clearTimeout(pending.timer);
|
|
1468
|
+
pendingAsks.delete(id);
|
|
1469
|
+
log("←", "ask:reply", parsed.text, parsed.tag);
|
|
1470
|
+
pending.resolve(parsed.text);
|
|
1471
|
+
res.json({ ok: true, routed: "ask" });
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const ts = event.ts ? new Date(Number(event.ts) * 1000).toISOString() : new Date().toISOString();
|
|
1475
|
+
const entry = {
|
|
1476
|
+
text: parsed.text,
|
|
1477
|
+
tag: parsed.tag,
|
|
1478
|
+
ts,
|
|
1479
|
+
};
|
|
1480
|
+
const out = ingestInboxEntry(entry, "slack");
|
|
1481
|
+
res.json({ ok: true, routed: "inbox", waiters: out.waiters, sse: out.sse });
|
|
1482
|
+
});
|
|
1281
1483
|
async function initTgOffset(token) {
|
|
1282
1484
|
const r = await fetch(`https://api.telegram.org/bot${token}/getUpdates?offset=-1&timeout=0`);
|
|
1283
1485
|
const json = await r.json();
|
|
@@ -1373,28 +1575,19 @@ async function startTelegramListener() {
|
|
|
1373
1575
|
writeInboxDrop(entry);
|
|
1374
1576
|
const liveSseCount = broadcastInbox(entry);
|
|
1375
1577
|
log("·", "inbox", `${text} (sse=${liveSseCount}, waiters=${waiters.length})`, tag);
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
// cheerfully claims "broadcast to 3 sessions" when 2 of them are
|
|
1379
|
-
// closed VS Code windows — which is exactly what prompted this fix.
|
|
1380
|
-
pruneDeadSessions();
|
|
1381
|
-
// Build an ack that names the active sessions the user's message
|
|
1382
|
-
// is being routed to. If the user tagged it and no session with
|
|
1383
|
-
// that tag is connected, tell them plainly so they don't sit
|
|
1384
|
-
// waiting for a reply that can't come.
|
|
1385
|
-
const targets = sessionsMatchingTag(tag);
|
|
1578
|
+
const sseCount = sseSubscribersForTag(tag);
|
|
1579
|
+
const anyoneListening = sseCount > 0 || waiters.length > 0;
|
|
1386
1580
|
let ackText;
|
|
1387
|
-
if (
|
|
1581
|
+
if (!anyoneListening && tag) {
|
|
1388
1582
|
ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
|
|
1389
1583
|
}
|
|
1390
|
-
else if (
|
|
1584
|
+
else if (!anyoneListening) {
|
|
1391
1585
|
ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
|
|
1392
1586
|
}
|
|
1393
1587
|
else {
|
|
1394
|
-
const names = targets.map(sessionDisplay).join(", ");
|
|
1395
1588
|
ackText = tag
|
|
1396
|
-
? `📬 Routed to
|
|
1397
|
-
: `📬 Broadcast to ${
|
|
1589
|
+
? `📬 Routed to @${tag}. Waiting for reply.`
|
|
1590
|
+
: `📬 Broadcast to ${sseCount} listener${sseCount === 1 ? "" : "s"}. Waiting for reply.`;
|
|
1398
1591
|
}
|
|
1399
1592
|
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
1400
1593
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
@@ -1420,6 +1613,158 @@ async function startTelegramListener() {
|
|
|
1420
1613
|
}
|
|
1421
1614
|
}
|
|
1422
1615
|
}
|
|
1616
|
+
// ── Slack inbound poller (always-on; folds slack-poll.sh → #16) ────────────────
|
|
1617
|
+
const SLACK_SECRETS_PATH = join(fileURLToPath(new URL("../../notify-secrets.json", import.meta.url)));
|
|
1618
|
+
function decodeB64Fields(obj) {
|
|
1619
|
+
if (Array.isArray(obj))
|
|
1620
|
+
return obj.map(decodeB64Fields);
|
|
1621
|
+
if (obj && typeof obj === "object") {
|
|
1622
|
+
const out = {};
|
|
1623
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1624
|
+
if (k.endsWith("_b64") && typeof v === "string")
|
|
1625
|
+
out[k.slice(0, -4)] = Buffer.from(v, "base64").toString("utf8");
|
|
1626
|
+
else
|
|
1627
|
+
out[k] = decodeB64Fields(v);
|
|
1628
|
+
}
|
|
1629
|
+
return out;
|
|
1630
|
+
}
|
|
1631
|
+
return obj;
|
|
1632
|
+
}
|
|
1633
|
+
function loadSecrets() {
|
|
1634
|
+
if (!existsSync(SLACK_SECRETS_PATH))
|
|
1635
|
+
return {};
|
|
1636
|
+
try {
|
|
1637
|
+
return decodeB64Fields(JSON.parse(readFileSync(SLACK_SECRETS_PATH, "utf8")));
|
|
1638
|
+
}
|
|
1639
|
+
catch (err) {
|
|
1640
|
+
log("·", "secrets", `load failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1641
|
+
return {};
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
function slackCreds() {
|
|
1645
|
+
const s = loadSecrets().slack ?? {};
|
|
1646
|
+
const cfgSlack = loadConfig().slack ?? {};
|
|
1647
|
+
const token = (process.env.SLACK_BOT_TOKEN ?? s.botToken ?? "").trim() || undefined;
|
|
1648
|
+
const channel = (process.env.SLACK_CHANNEL_ID ?? s.channelId ?? "").trim() || undefined;
|
|
1649
|
+
const webhook = (s.webhookUrl ?? cfgSlack.webhookUrl ?? "").trim() || undefined;
|
|
1650
|
+
return { token, channel, webhook };
|
|
1651
|
+
}
|
|
1652
|
+
async function slackPost(text) {
|
|
1653
|
+
const { webhook } = slackCreds();
|
|
1654
|
+
if (!webhook)
|
|
1655
|
+
return;
|
|
1656
|
+
try {
|
|
1657
|
+
await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }) });
|
|
1658
|
+
}
|
|
1659
|
+
catch { /* webhook post is best-effort */ }
|
|
1660
|
+
}
|
|
1661
|
+
function slackClientTags() {
|
|
1662
|
+
const tags = new Set();
|
|
1663
|
+
for (const sess of listActiveSessions())
|
|
1664
|
+
if (sess.tag)
|
|
1665
|
+
tags.add(sess.tag);
|
|
1666
|
+
for (const c of inboxStreamClients)
|
|
1667
|
+
if (c.tag)
|
|
1668
|
+
tags.add(c.tag);
|
|
1669
|
+
for (const [, w] of inboxWaiters)
|
|
1670
|
+
if (w.tag)
|
|
1671
|
+
tags.add(w.tag);
|
|
1672
|
+
return [...tags].sort();
|
|
1673
|
+
}
|
|
1674
|
+
function slackClientsNumbered() {
|
|
1675
|
+
const tags = slackClientTags();
|
|
1676
|
+
return tags.length ? tags.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none connected)";
|
|
1677
|
+
}
|
|
1678
|
+
function resolveSlackClient(handle) {
|
|
1679
|
+
const tags = slackClientTags();
|
|
1680
|
+
if (/^[0-9]+$/.test(handle))
|
|
1681
|
+
return tags[parseInt(handle, 10) - 1];
|
|
1682
|
+
return tags.find(t => t === handle);
|
|
1683
|
+
}
|
|
1684
|
+
async function handleSlackCommand(lc) {
|
|
1685
|
+
if (lc === "list clients" || lc === "clients" || lc === "list") {
|
|
1686
|
+
await slackPost(`Connected clients — reply @<name> or #<id>:\n${slackClientsNumbered()}`);
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
if (lc === "help" || lc === "commands" || lc === "?") {
|
|
1690
|
+
await slackPost("Commands: `clients`. Direct: `@<name> msg`, `#<id> msg`, or untagged → broadcast to all.");
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
return false;
|
|
1694
|
+
}
|
|
1695
|
+
let slackCursor = "";
|
|
1696
|
+
let slackConsecutiveErrors = 0;
|
|
1697
|
+
async function pollSlackOnce(token, channel) {
|
|
1698
|
+
const url = `https://slack.com/api/conversations.history?channel=${encodeURIComponent(channel)}&oldest=${encodeURIComponent(slackCursor)}&limit=50`;
|
|
1699
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
1700
|
+
const json = await r.json();
|
|
1701
|
+
if (!json.ok)
|
|
1702
|
+
throw new Error(`conversations.history: ${json.error ?? "unknown"}`);
|
|
1703
|
+
const all = json.messages ?? [];
|
|
1704
|
+
const human = all
|
|
1705
|
+
.filter(m => !m.subtype && !m.bot_id && !m.app_id && m.user && String(m.text ?? "").trim())
|
|
1706
|
+
.sort((a, b) => Number(a.ts) - Number(b.ts));
|
|
1707
|
+
for (const m of human) {
|
|
1708
|
+
const text = String(m.text ?? "");
|
|
1709
|
+
const stripped = text.replace(/<@[A-Za-z0-9]+>/g, "");
|
|
1710
|
+
const lc = stripped.toLowerCase().trim();
|
|
1711
|
+
if (await handleSlackCommand(lc)) {
|
|
1712
|
+
log("←", "slack:cmd", lc);
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
const clean = stripped.replace(/^\s+/, "");
|
|
1716
|
+
const routed = clean.match(/^[@#]([^\s]+)\s+([\s\S]*)$/);
|
|
1717
|
+
if (routed) {
|
|
1718
|
+
const handle = routed[1];
|
|
1719
|
+
const msg = routed[2];
|
|
1720
|
+
const tag = resolveSlackClient(handle);
|
|
1721
|
+
if (!tag) {
|
|
1722
|
+
await slackPost(`❌ Unknown client "${handle}". Connected:\n${slackClientsNumbered()}`);
|
|
1723
|
+
log("←", "slack", `unknown client: ${handle}`);
|
|
1724
|
+
continue;
|
|
1725
|
+
}
|
|
1726
|
+
ingestInboxEntry({ text: msg, ts: new Date().toISOString(), tag, origin: "slack" }, "slack");
|
|
1727
|
+
await slackPost(sessionBusyNote(tag) || "ack");
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
ingestInboxEntry({ text, ts: new Date().toISOString(), origin: "slack" }, "slack");
|
|
1731
|
+
await slackPost("ack");
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const newest = all.reduce((acc, m) => (Number(m.ts) > Number(acc || 0) ? String(m.ts) : acc), slackCursor);
|
|
1735
|
+
if (newest)
|
|
1736
|
+
slackCursor = newest;
|
|
1737
|
+
}
|
|
1738
|
+
async function startSlackListener() {
|
|
1739
|
+
const interval = (Number(process.env.SLACK_POLL_INTERVAL) || 2) * 1000;
|
|
1740
|
+
while (true) {
|
|
1741
|
+
try {
|
|
1742
|
+
const { token, channel } = slackCreds();
|
|
1743
|
+
if (!token || !channel) {
|
|
1744
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (!slackCursor) {
|
|
1748
|
+
slackCursor = String(Math.floor(Date.now() / 1000) - 300);
|
|
1749
|
+
log("·", "slack", `listener ready, channel=${channel}, backfill=300s (closes restart gap)`);
|
|
1750
|
+
}
|
|
1751
|
+
await pollSlackOnce(token, channel);
|
|
1752
|
+
if (slackConsecutiveErrors > 0) {
|
|
1753
|
+
log("·", "slack", `recovered after ${slackConsecutiveErrors} attempt(s)`);
|
|
1754
|
+
slackConsecutiveErrors = 0;
|
|
1755
|
+
}
|
|
1756
|
+
await new Promise(r => setTimeout(r, interval));
|
|
1757
|
+
}
|
|
1758
|
+
catch (err) {
|
|
1759
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1760
|
+
if (!msg.includes("terminated") && !msg.includes("aborted"))
|
|
1761
|
+
log("·", "slack:error", msg);
|
|
1762
|
+
slackConsecutiveErrors++;
|
|
1763
|
+
const delay = Math.min(60_000, 2000 * Math.pow(2, Math.min(5, slackConsecutiveErrors - 1)));
|
|
1764
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1423
1768
|
app.get("/reply/:token", (req, res) => {
|
|
1424
1769
|
const pending = pendingAsks.get(req.params.token);
|
|
1425
1770
|
res.send(`<!DOCTYPE html><html><head><title>Reply to Claude</title>
|
|
@@ -1890,6 +2235,22 @@ function sessionsMatchingTag(tag) {
|
|
|
1890
2235
|
return listActiveSessions();
|
|
1891
2236
|
return listActiveSessions().filter(s => s.tag === tag);
|
|
1892
2237
|
}
|
|
2238
|
+
// Count live SSE subscribers on /api/inbox/stream that would receive a message
|
|
2239
|
+
// with the given tag. Stdio-bridge clients subscribe via SSE but don't always
|
|
2240
|
+
// appear in sessions[] (their /mcp initialize session can get reaped while the
|
|
2241
|
+
// SSE stream stays alive). Without counting them, the Telegram ack lies with
|
|
2242
|
+
// "no agents connected" even when a bridge is actively listening.
|
|
2243
|
+
function sseSubscribersForTag(tag) {
|
|
2244
|
+
let n = 0;
|
|
2245
|
+
for (const c of inboxStreamClients) {
|
|
2246
|
+
if (c.res.destroyed || c.res.writableEnded || !c.res.writable)
|
|
2247
|
+
continue;
|
|
2248
|
+
if (tag && c.tag !== tag)
|
|
2249
|
+
continue;
|
|
2250
|
+
n++;
|
|
2251
|
+
}
|
|
2252
|
+
return n;
|
|
2253
|
+
}
|
|
1893
2254
|
// Synchronous best-effort liveness check before we count sessions in an ack.
|
|
1894
2255
|
// The transport's SDK doesn't expose a "ping" API, but it does hold a ref to
|
|
1895
2256
|
// the response stream of the last GET the client opened — if that stream is
|
|
@@ -1926,6 +2287,13 @@ function sessionDisplay(s) {
|
|
|
1926
2287
|
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1927
2288
|
}
|
|
1928
2289
|
app.all("/mcp", async (req, res) => {
|
|
2290
|
+
if (!ENABLE_MCP) {
|
|
2291
|
+
res.status(404).json({
|
|
2292
|
+
error: "mcp_disabled",
|
|
2293
|
+
message: "MCP transport is disabled. Set ENABLE_MCP=1 to enable /mcp.",
|
|
2294
|
+
});
|
|
2295
|
+
return;
|
|
2296
|
+
}
|
|
1929
2297
|
console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
|
|
1930
2298
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1931
2299
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
@@ -2059,8 +2427,14 @@ function trackReconnect(clientId) {
|
|
|
2059
2427
|
const httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
2060
2428
|
const ip = getLocalIp();
|
|
2061
2429
|
console.log(`\n Claude Notify config UI → http://localhost:${PORT}`);
|
|
2062
|
-
|
|
2430
|
+
if (ENABLE_MCP) {
|
|
2431
|
+
console.log(` MCP endpoint (remote) → http://${ip}:${PORT}/mcp\n`);
|
|
2432
|
+
}
|
|
2433
|
+
else {
|
|
2434
|
+
console.log(" MCP endpoint (remote) → disabled (set ENABLE_MCP=1 to enable)\n");
|
|
2435
|
+
}
|
|
2063
2436
|
startTelegramListener();
|
|
2437
|
+
startSlackListener();
|
|
2064
2438
|
open(`http://localhost:${PORT}`).catch(() => { });
|
|
2065
2439
|
});
|
|
2066
2440
|
// TCP-level keepalive on every incoming socket. Without this, a client that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
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/help.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<div class="tabs">
|
|
41
41
|
<button class="tab tab-active" data-tab="claude-code">Claude Code (CLI)</button>
|
|
42
42
|
<button class="tab" data-tab="cursor">Cursor</button>
|
|
43
|
-
<button class="tab" data-tab="vscode">VS Code</button>
|
|
43
|
+
<button class="tab" data-tab="vscode">VS Code + Copilot</button>
|
|
44
44
|
<button class="tab" data-tab="claude-desktop">Claude Desktop</button>
|
|
45
45
|
<button class="tab" data-tab="windsurf">Windsurf</button>
|
|
46
46
|
<button class="tab" data-tab="zed">Zed</button>
|
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
}</code></pre>
|
|
126
126
|
</div>
|
|
127
127
|
<p class="hint">Or via Command Palette → <em>MCP: Add Server</em> → npm package → <code>omni-notify-mcp</code>.</p>
|
|
128
|
+
<p class="hint"><strong>Important for GitHub Copilot Chat/Agent:</strong> Copilot can miss server-pushed notifications. For reliable inbound user messages, the agent should loop on <code>wait_for_inbox(timeout_seconds=50)</code> and handle tool results immediately.</p>
|
|
128
129
|
</div>
|
|
129
130
|
|
|
130
131
|
<!-- Claude Desktop -->
|
|
@@ -230,16 +231,18 @@
|
|
|
230
231
|
|
|
231
232
|
<section class="help-section">
|
|
232
233
|
<h2>What the AI gets</h2>
|
|
233
|
-
<p>
|
|
234
|
+
<p>Eight tools, all server-configured. The agent never names a channel — it just calls <code>notify</code>/<code>ask</code> and your settings decide where it goes:</p>
|
|
234
235
|
<table class="tools-table">
|
|
235
236
|
<tr><td><code>notify</code></td><td>Send a message. <code>priority=low|normal|high</code> controls fan-out and bypasses DND when high.</td></tr>
|
|
236
237
|
<tr><td><code>ask</code></td><td>Send a question and <strong>wait for your reply</strong> (Telegram or email link).</td></tr>
|
|
237
238
|
<tr><td><code>poll</code></td><td>Drain unsolicited messages you sent the agent.</td></tr>
|
|
239
|
+
<tr><td><code>wait_for_inbox</code></td><td>Long-poll up to 50s and return immediately when a new inbound user message exists.</td></tr>
|
|
238
240
|
<tr><td><code>get_idle_seconds</code></td><td>Seconds since your last keyboard/mouse input.</td></tr>
|
|
239
241
|
<tr><td><code>get_idle_config</code></td><td>The server's idle-gating policy.</td></tr>
|
|
240
242
|
<tr><td><code>get_dnd_status</code></td><td>Whether Do Not Disturb is currently suppressing.</td></tr>
|
|
243
|
+
<tr><td><code>reply</code></td><td>Channel return-path for stdio clients that support pushed channel events.</td></tr>
|
|
241
244
|
</table>
|
|
242
|
-
<p class="hint">
|
|
245
|
+
<p class="hint">For Copilot, treat <code>wait_for_inbox</code> as the primary inbound path. Keep a tight loop and process every non-empty result before continuing long tasks.</p>
|
|
243
246
|
</section>
|
|
244
247
|
|
|
245
248
|
<section class="help-section">
|