omni-notify-mcp 1.1.6 → 1.1.8
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 +9 -0
- package/assets/screenshots/help-page.png +0 -0
- package/assets/screenshots/main-ui.png +0 -0
- package/dist/ui/server.js +223 -26
- package/package.json +1 -1
- package/ui/public/app.js +85 -6
- package/ui/public/index.html +9 -5
- package/ui/public/style.css +56 -6
package/README.md
CHANGED
|
@@ -106,6 +106,15 @@ Run multiple agents against the same notify server (e.g. one Claude session in `
|
|
|
106
106
|
- `priority='high'` always punches through.
|
|
107
107
|
- Agents can pre-flight with `get_dnd_status` to skip the round-trip when DND is on.
|
|
108
108
|
|
|
109
|
+
### Heartbeat-drain (stay responsive during long work)
|
|
110
|
+
Every agent that calls `get_idle_seconds` or `get_dnd_status` while busy gets any pending user inbox messages piggy-backed on the response. The server-side MCP `instructions` tell agents to call `get_idle_seconds` every 15-30 seconds during long operations so a user ping from Telegram lands within 30 seconds even if the agent hasn't called `notify` in hours. When an inbox message lands, the agent is required to fire a terse `busy-ack` back via `notify` so the user knows they were heard — even if the full response comes later.
|
|
111
|
+
|
|
112
|
+
### Multi-session broadcast
|
|
113
|
+
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
|
+
|
|
115
|
+
### 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.
|
|
117
|
+
|
|
109
118
|
### Idle gating (anti-buzz)
|
|
110
119
|
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.
|
|
111
120
|
|
|
Binary file
|
|
Binary file
|
package/dist/ui/server.js
CHANGED
|
@@ -551,6 +551,17 @@ function log(direction, channel, text, client) {
|
|
|
551
551
|
catch { }
|
|
552
552
|
}
|
|
553
553
|
}
|
|
554
|
+
app.get("/api/sessions", (_req, res) => {
|
|
555
|
+
const list = listActiveSessions().map(s => ({
|
|
556
|
+
clientId: s.clientId,
|
|
557
|
+
tag: s.tag,
|
|
558
|
+
clientName: s.clientName,
|
|
559
|
+
clientVersion: s.clientVersion,
|
|
560
|
+
host: s.host,
|
|
561
|
+
connectedAt: s.connectedAt,
|
|
562
|
+
}));
|
|
563
|
+
res.json({ sessions: list });
|
|
564
|
+
});
|
|
554
565
|
app.get("/api/logs", (req, res) => {
|
|
555
566
|
res.setHeader("Content-Type", "text/event-stream");
|
|
556
567
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -578,8 +589,11 @@ async function sendNotification(message, priority, client) {
|
|
|
578
589
|
// they may have multiple agents running and this is a cheap local signal
|
|
579
590
|
// that doesn't blast their phone. Disable via idle.alwaysDesktopWhenActive=false.
|
|
580
591
|
// priority=high always bypasses idle entirely.
|
|
592
|
+
// Conversation bypass: if the user just messaged us from Telegram (within
|
|
593
|
+
// the TTL), they clearly want a reply over that channel, so skip idle gating.
|
|
594
|
+
const inTelegramConvo = Date.now() - lastTelegramInboundAt < TELEGRAM_CONVO_TTL_MS;
|
|
581
595
|
let desktopOnlyMode = false;
|
|
582
|
-
if (priority !== "high" && cfg.idle?.enabled !== false) {
|
|
596
|
+
if (priority !== "high" && !inTelegramConvo && cfg.idle?.enabled !== false) {
|
|
583
597
|
const idleSecs = getOsIdleSeconds();
|
|
584
598
|
const threshold = cfg.idle?.thresholdSeconds ?? 120;
|
|
585
599
|
const userIsActive = idleSecs >= 0 && idleSecs < threshold;
|
|
@@ -594,6 +608,9 @@ async function sendNotification(message, priority, client) {
|
|
|
594
608
|
}
|
|
595
609
|
}
|
|
596
610
|
}
|
|
611
|
+
else if (priority !== "high" && inTelegramConvo) {
|
|
612
|
+
log("·", "idle", `bypassed (telegram convo active)`, client);
|
|
613
|
+
}
|
|
597
614
|
const send = async (name, fn) => {
|
|
598
615
|
try {
|
|
599
616
|
await fn();
|
|
@@ -732,6 +749,13 @@ const pendingAsks = new Map();
|
|
|
732
749
|
const inboxQueue = [];
|
|
733
750
|
let tgPollOffset = -1;
|
|
734
751
|
let lastUserMessageId;
|
|
752
|
+
// When the user pings us from Telegram, bypass idle-gating on outbound
|
|
753
|
+
// notifs for a while — clearly they want a Telegram reply back, so we
|
|
754
|
+
// shouldn't gate remote channels just because they're at the keyboard
|
|
755
|
+
// typing. TTL is short so normal idle-gating resumes once the conversation
|
|
756
|
+
// goes quiet.
|
|
757
|
+
let lastTelegramInboundAt = 0;
|
|
758
|
+
const TELEGRAM_CONVO_TTL_MS = 5 * 60 * 1000;
|
|
735
759
|
// Session tagging: a session may declare a tag (e.g. "alphawave") when it
|
|
736
760
|
// connects to /mcp?tag=alphawave. Telegram messages starting with "@<tag>"
|
|
737
761
|
// are routed only to sessions with that exact tag (tag prefix stripped).
|
|
@@ -844,6 +868,7 @@ async function startTelegramListener() {
|
|
|
844
868
|
if (msg?.chat?.id?.toString() === chatId && msg.text) {
|
|
845
869
|
log("←", "telegram", msg.text);
|
|
846
870
|
lastUserMessageId = msg.message_id;
|
|
871
|
+
lastTelegramInboundAt = Date.now();
|
|
847
872
|
const { tag, text } = parseTag(msg.text);
|
|
848
873
|
// Match an outstanding ask first. If the message is tagged, only
|
|
849
874
|
// route to a pending ask from that same session — otherwise fall
|
|
@@ -863,13 +888,24 @@ async function startTelegramListener() {
|
|
|
863
888
|
inboxQueue.push(entry);
|
|
864
889
|
broadcastInbox(entry);
|
|
865
890
|
log("·", "inbox", text, tag);
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
891
|
+
// Build an ack that names the active sessions the user's message
|
|
892
|
+
// is being routed to. If the user tagged it and no session with
|
|
893
|
+
// that tag is connected, tell them plainly so they don't sit
|
|
894
|
+
// waiting for a reply that can't come.
|
|
895
|
+
const targets = sessionsMatchingTag(tag);
|
|
896
|
+
let ackText;
|
|
897
|
+
if (tag && targets.length === 0) {
|
|
898
|
+
ackText = `📭 No session @${tag} connected. Message queued — next @${tag} to connect will pick it up.`;
|
|
899
|
+
}
|
|
900
|
+
else if (targets.length === 0) {
|
|
901
|
+
ackText = `📭 No agents connected. Message queued — next agent to connect will pick it up.`;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
const names = targets.map(sessionDisplay).join(", ");
|
|
905
|
+
ackText = tag
|
|
906
|
+
? `📬 Routed to ${names}. Waiting for them to reply.`
|
|
907
|
+
: `📬 Broadcast to ${targets.length} session(s): ${names}. Each should reply with its identity — respond to the one you want.`;
|
|
908
|
+
}
|
|
873
909
|
fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
874
910
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
875
911
|
body: JSON.stringify({
|
|
@@ -933,6 +969,18 @@ function drainInboxFor(tag) {
|
|
|
933
969
|
}
|
|
934
970
|
return taken;
|
|
935
971
|
}
|
|
972
|
+
// Appends an inbox block to any tool's text payload when messages are pending
|
|
973
|
+
// for the given session. Lets cheap read tools (get_idle_seconds, get_dnd_status)
|
|
974
|
+
// double as inbox drains, so a busy agent calling them as a keepalive still
|
|
975
|
+
// sees user messages even when it hasn't called notify/poll in a while.
|
|
976
|
+
function appendInbox(baseText, sessionTag, clientId) {
|
|
977
|
+
const messages = drainInboxFor(sessionTag);
|
|
978
|
+
if (messages.length === 0)
|
|
979
|
+
return baseText;
|
|
980
|
+
log("·", "poll", `${messages.length} message(s) drained via heartbeat`, clientId);
|
|
981
|
+
const inbox = messages.map(m => `[${m.ts}] ${m.text}`).join("\n");
|
|
982
|
+
return `${baseText}\n\n⚠️ USER SENT YOU A MESSAGE — STOP AND RESPOND BEFORE CONTINUING:\n${inbox}`;
|
|
983
|
+
}
|
|
936
984
|
const MCP_INSTRUCTIONS = `
|
|
937
985
|
This server delivers notifications to the user through whatever channels the
|
|
938
986
|
user has configured on the server side. The client never needs to know which
|
|
@@ -963,13 +1011,85 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
963
1011
|
user has already configured. Say 'notif' or 'notification' instead.
|
|
964
1012
|
|
|
965
1013
|
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
966
|
-
the 'notify' response, via 'poll',
|
|
967
|
-
to them THROUGH 'notify' so the reply
|
|
968
|
-
your chat output.
|
|
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
|
|
1016
|
+
actually reaches them — not just in your chat output. Multiple agents may
|
|
1017
|
+
be connected simultaneously — the server broadcasts every untagged inbox
|
|
1018
|
+
message to all of them, so the user can see who is listening. Your reply
|
|
1019
|
+
MUST identify which session you are (start with your own tag or client id,
|
|
1020
|
+
e.g. "[@alphawave]" or "[sess-abcd]") and give a brief status so the user
|
|
1021
|
+
can pick whom to respond to. If the user tagged their message (@<tag>),
|
|
1022
|
+
only the session with that tag should reply. If you are untagged and
|
|
1023
|
+
another session with the same project/workdir is already tagged, let the
|
|
1024
|
+
tagged one reply.
|
|
1025
|
+
|
|
1026
|
+
5a. BUSY-ACK RULE (hard requirement): The moment you notice a user inbox
|
|
1027
|
+
message — whether via notify response, heartbeat piggy-back, or poll —
|
|
1028
|
+
REPLY IMMEDIATELY via 'notify' with a terse status, EVEN IF YOU CAN'T
|
|
1029
|
+
ACT ON THE REQUEST YET. Do not finish the current tool/task first.
|
|
1030
|
+
Drop what you're doing long enough to fire one 'notify' call, THEN
|
|
1031
|
+
resume. Format: "[<your-session-id>] busy on <current-task>; will
|
|
1032
|
+
respond at <milestone>". The user needs to know (a) you heard them,
|
|
1033
|
+
(b) you're not the stuck one, (c) when to expect a real answer. An
|
|
1034
|
+
inbox message that gets a delayed reply is worse than no reply — it
|
|
1035
|
+
wastes the user's time waiting on silence.
|
|
1036
|
+
|
|
1037
|
+
6. The 'get_idle_seconds', 'get_idle_config', and 'get_dnd_status' tools are
|
|
1038
|
+
informational reads, but they ALSO drain pending inbox messages. Use them
|
|
1039
|
+
as a cheap heartbeat during long work: call 'get_idle_seconds' EVERY 15-
|
|
1040
|
+
30 SECONDS while a long task runs (loop iteration, backtest, scan, build,
|
|
1041
|
+
etc.). If the user sent you a message while you were busy, it comes back
|
|
1042
|
+
piggy-backed on the response — you don't need to separately call 'poll'.
|
|
1043
|
+
This is a lightweight local read (no network, no DND or channel routing),
|
|
1044
|
+
so the cost is near zero. Without this heartbeat pattern, a busy agent is
|
|
1045
|
+
deaf to the user until its next 'notify' call — which may be minutes or
|
|
1046
|
+
hours away during long work. Treat 'get_idle_seconds' as the "check for
|
|
1047
|
+
user input" primitive, not an idle-gate check.
|
|
1048
|
+
|
|
1049
|
+
7. If your tool call fails with "MCP server not connected" / "transport
|
|
1050
|
+
closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
|
|
1051
|
+
connected to the same server right now. Only YOUR client's transport
|
|
1052
|
+
broke (commonly: stdio child process died, or HTTP client gave up after a
|
|
1053
|
+
transient slow response at startup). DO NOT tell the user "notify is down"
|
|
1054
|
+
without first probing the server directly.
|
|
1055
|
+
|
|
1056
|
+
Probe before declaring the server dead:
|
|
1057
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3737/mcp
|
|
1058
|
+
- 406 (Not Acceptable) = server is healthy, doing MCP protocol
|
|
1059
|
+
negotiation. Your client is the broken party. Use the bypass below.
|
|
1060
|
+
- Connection refused / timeout = server is genuinely down. Tell the user.
|
|
1061
|
+
|
|
1062
|
+
HTTP-transport bypass when your MCP client is broken (works from any
|
|
1063
|
+
shell that has curl). Three calls, stateful — the session id from step 1
|
|
1064
|
+
must be passed in mcp-session-id on every subsequent call:
|
|
969
1065
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1066
|
+
# 1) initialize and capture session id from response headers
|
|
1067
|
+
SID=$(curl -s -i -X POST http://localhost:3737/mcp \
|
|
1068
|
+
-H "Content-Type: application/json" \
|
|
1069
|
+
-H "Accept: application/json, text/event-stream" \
|
|
1070
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-bridge","version":"1.0"}}}' \
|
|
1071
|
+
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
|
|
1072
|
+
|
|
1073
|
+
# 2) ack initialized (no response body, fire-and-forget)
|
|
1074
|
+
curl -s -X POST http://localhost:3737/mcp \
|
|
1075
|
+
-H "Content-Type: application/json" \
|
|
1076
|
+
-H "Accept: application/json, text/event-stream" \
|
|
1077
|
+
-H "mcp-session-id: $SID" \
|
|
1078
|
+
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
|
|
1079
|
+
|
|
1080
|
+
# 3) call any tool — successful response includes "Sent via: <channel>"
|
|
1081
|
+
curl -s -X POST http://localhost:3737/mcp \
|
|
1082
|
+
-H "Content-Type: application/json" \
|
|
1083
|
+
-H "Accept: application/json, text/event-stream" \
|
|
1084
|
+
-H "mcp-session-id: $SID" \
|
|
1085
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notify","arguments":{"message":"YOUR MESSAGE","priority":"normal"}}}'
|
|
1086
|
+
|
|
1087
|
+
Same pattern works for 'ask', 'poll', 'get_idle_seconds', etc. — change
|
|
1088
|
+
the "name" and "arguments" in step 3. Reuse the same $SID for all calls
|
|
1089
|
+
in one bypass session.
|
|
1090
|
+
|
|
1091
|
+
Bottom line: rule #1 still applies. If you owe the user a notify and your
|
|
1092
|
+
MCP client is broken, you owe them the bypass — not an excuse.
|
|
973
1093
|
`.trim();
|
|
974
1094
|
function createMcpServer(clientId, sessionTag) {
|
|
975
1095
|
const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: MCP_INSTRUCTIONS });
|
|
@@ -1075,23 +1195,25 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
1075
1195
|
};
|
|
1076
1196
|
});
|
|
1077
1197
|
server.tool("get_idle_seconds", "Returns the number of seconds since the user's last keyboard/mouse input. " +
|
|
1078
|
-
"Call this
|
|
1079
|
-
"
|
|
1080
|
-
"
|
|
1198
|
+
"Call this periodically during long work as a cheap heartbeat — the server " +
|
|
1199
|
+
"will piggy-back any pending inbox messages in the response, so you stay " +
|
|
1200
|
+
"responsive to the user without having to call poll. Returns -1 if idle " +
|
|
1201
|
+
"detection is unsupported on this platform — in that case, proceed without " +
|
|
1202
|
+
"gating (fail-open).", {}, async () => {
|
|
1081
1203
|
const secs = getOsIdleSeconds();
|
|
1082
|
-
return { content: [{ type: "text", text: String(secs) }] };
|
|
1204
|
+
return { content: [{ type: "text", text: appendInbox(String(secs), sessionTag, clientId) }] };
|
|
1083
1205
|
});
|
|
1084
1206
|
server.tool("get_idle_config", "Returns the server's idle gating policy: { enabled, thresholdSeconds, alwaysDesktopWhenActive }. " +
|
|
1085
1207
|
"Informational only — the server gates internally on every notify call. " +
|
|
1086
|
-
"
|
|
1208
|
+
"Also drains pending inbox messages — safe to use as a heartbeat.", {}, async () => {
|
|
1087
1209
|
const cfg = loadConfig();
|
|
1088
1210
|
const idle = cfg.idle ?? { enabled: true, thresholdSeconds: 120, alwaysDesktopWhenActive: true };
|
|
1089
|
-
return { content: [{ type: "text", text: JSON.stringify(idle) }] };
|
|
1211
|
+
return { content: [{ type: "text", text: appendInbox(JSON.stringify(idle), sessionTag, clientId) }] };
|
|
1090
1212
|
});
|
|
1091
1213
|
server.tool("get_dnd_status", "Returns the current DND state: " +
|
|
1092
1214
|
"{ active: boolean, reason: 'manual' | 'schedule' | 'off' }. " +
|
|
1093
1215
|
"When active, the server will suppress delivery for priority < high. " +
|
|
1094
|
-
"
|
|
1216
|
+
"Also drains pending inbox messages — safe to use as a heartbeat.", {}, async () => {
|
|
1095
1217
|
const cfg = loadConfig();
|
|
1096
1218
|
const active = isDndActive(cfg);
|
|
1097
1219
|
let reason = "off";
|
|
@@ -1099,31 +1221,106 @@ function createMcpServer(clientId, sessionTag) {
|
|
|
1099
1221
|
reason = cfg.dnd?.enabled ? "manual" : "schedule";
|
|
1100
1222
|
}
|
|
1101
1223
|
return {
|
|
1102
|
-
content: [{ type: "text", text: JSON.stringify({ active, reason }) }],
|
|
1224
|
+
content: [{ type: "text", text: appendInbox(JSON.stringify({ active, reason }), sessionTag, clientId) }],
|
|
1103
1225
|
};
|
|
1104
1226
|
});
|
|
1105
1227
|
return server;
|
|
1106
1228
|
}
|
|
1107
1229
|
const httpTransports = {};
|
|
1230
|
+
const sessions = {};
|
|
1231
|
+
// Reap sessions that haven't made any request in a while. Keeps the sessions
|
|
1232
|
+
// list and pills bar accurate even when clients vanish without closing their
|
|
1233
|
+
// transport (VS Code window closed, laptop lid shut, network died). On next
|
|
1234
|
+
// reconnect the client gets a 404 and reinitializes cleanly.
|
|
1235
|
+
const SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
1236
|
+
setInterval(() => {
|
|
1237
|
+
const now = Date.now();
|
|
1238
|
+
for (const [sessionId, meta] of Object.entries(sessions)) {
|
|
1239
|
+
if (now - meta.lastSeen > SESSION_IDLE_TIMEOUT_MS) {
|
|
1240
|
+
log("·", "session", `reaped idle session ${meta.clientId} (last seen ${Math.round((now - meta.lastSeen) / 1000)}s ago)`);
|
|
1241
|
+
try {
|
|
1242
|
+
httpTransports[sessionId]?.close();
|
|
1243
|
+
}
|
|
1244
|
+
catch { /* ignore */ }
|
|
1245
|
+
delete httpTransports[sessionId];
|
|
1246
|
+
delete sessions[sessionId];
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}, 60_000);
|
|
1250
|
+
function listActiveSessions() {
|
|
1251
|
+
return Object.values(sessions);
|
|
1252
|
+
}
|
|
1253
|
+
function sessionsMatchingTag(tag) {
|
|
1254
|
+
if (!tag)
|
|
1255
|
+
return listActiveSessions();
|
|
1256
|
+
return listActiveSessions().filter(s => s.tag === tag);
|
|
1257
|
+
}
|
|
1258
|
+
function sessionDisplay(s) {
|
|
1259
|
+
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1260
|
+
}
|
|
1108
1261
|
app.all("/mcp", async (req, res) => {
|
|
1109
1262
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1110
1263
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
1111
1264
|
await httpTransports[existingSessionId].handleRequest(req, res, req.body);
|
|
1265
|
+
// Lazy-populate clientInfo after initialize lands on an existing session.
|
|
1266
|
+
const meta = sessions[existingSessionId];
|
|
1267
|
+
if (meta)
|
|
1268
|
+
meta.lastSeen = Date.now();
|
|
1269
|
+
const mcpServer = httpTransports[existingSessionId].__mcpServer;
|
|
1270
|
+
if (meta && !meta.clientName && mcpServer?.getClientVersion) {
|
|
1271
|
+
try {
|
|
1272
|
+
const info = mcpServer.getClientVersion();
|
|
1273
|
+
if (info) {
|
|
1274
|
+
meta.clientName = info.name;
|
|
1275
|
+
meta.clientVersion = info.version;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
catch { /* ignore */ }
|
|
1279
|
+
}
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// Spec-compliant: if the client presents a session ID we don't know about
|
|
1283
|
+
// (server was restarted, or the transport was closed), return 404 so the
|
|
1284
|
+
// client knows to re-initialize. The previous silent-new-session behavior
|
|
1285
|
+
// left idle clients stuck with stale session bookkeeping until the VS Code
|
|
1286
|
+
// window was manually reloaded.
|
|
1287
|
+
if (existingSessionId) {
|
|
1288
|
+
res.status(404).json({
|
|
1289
|
+
jsonrpc: "2.0",
|
|
1290
|
+
error: { code: -32000, message: "Session not found — reinitialize" },
|
|
1291
|
+
id: null,
|
|
1292
|
+
});
|
|
1112
1293
|
return;
|
|
1113
1294
|
}
|
|
1114
1295
|
const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
|
|
1115
1296
|
const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
|
|
1116
1297
|
const newSessionId = randomUUID();
|
|
1117
|
-
const
|
|
1298
|
+
const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
|
|
1299
|
+
const port = req.socket.remotePort;
|
|
1300
|
+
// Build a distinguishable client id: tag wins if set; otherwise use host+port
|
|
1301
|
+
// so two untagged sessions from the same machine are still distinguishable.
|
|
1302
|
+
const clientId = sessionTag
|
|
1303
|
+
?? (host && port ? `${host === "127.0.0.1" || host === "::1" ? "local" : host}:${port}` : `sess-${newSessionId.slice(0, 8)}`);
|
|
1118
1304
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
|
|
1119
1305
|
transport.onclose = () => {
|
|
1120
|
-
if (transport.sessionId)
|
|
1306
|
+
if (transport.sessionId) {
|
|
1121
1307
|
delete httpTransports[transport.sessionId];
|
|
1308
|
+
delete sessions[transport.sessionId];
|
|
1309
|
+
}
|
|
1122
1310
|
};
|
|
1123
|
-
|
|
1311
|
+
const mcpServer = createMcpServer(clientId, sessionTag);
|
|
1312
|
+
await mcpServer.connect(transport);
|
|
1313
|
+
// Stash the underlying MCP server on the transport so subsequent requests
|
|
1314
|
+
// can grab clientInfo once the initialize handshake completes.
|
|
1315
|
+
transport.__mcpServer = mcpServer.server ?? mcpServer;
|
|
1124
1316
|
await transport.handleRequest(req, res, req.body);
|
|
1125
|
-
if (transport.sessionId)
|
|
1317
|
+
if (transport.sessionId) {
|
|
1126
1318
|
httpTransports[transport.sessionId] = transport;
|
|
1319
|
+
const now = Date.now();
|
|
1320
|
+
sessions[transport.sessionId] = {
|
|
1321
|
+
clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1127
1324
|
});
|
|
1128
1325
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
1129
1326
|
app.listen(PORT, "0.0.0.0", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "An MCP server that lets AI agents (Claude, Cursor, etc.) reach you on any channel — desktop, Telegram, SMS, email — with two-way ask/reply, real-time inbox push, Do Not Disturb, idle gating, multi-session routing, and a one-page web UI for setup. Zero config code; configure once, agents call notify/ask.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
package/ui/public/app.js
CHANGED
|
@@ -117,14 +117,16 @@ function updateBadges() {
|
|
|
117
117
|
email.connectedEmail ? "Connected" : email.clientId ? "Credentials saved" : "Not configured");
|
|
118
118
|
|
|
119
119
|
const tg = config.telegram ?? {};
|
|
120
|
+
const tgReady = tg.token && tg.chatId;
|
|
120
121
|
setBadge("telegram",
|
|
121
|
-
tg.enabled &&
|
|
122
|
-
tg.enabled &&
|
|
122
|
+
tg.enabled && tgReady ? "ok" : tgReady ? "warn" : tg.token ? "warn" : "idle",
|
|
123
|
+
tg.enabled && tgReady ? "Configured" : tgReady ? "Disabled" : tg.token ? "Incomplete" : "Not configured");
|
|
123
124
|
|
|
124
|
-
const sms = config.sms ?? {};
|
|
125
|
+
const sms = config.sms ?? {};
|
|
126
|
+
const smsReady = sms.accountSid && sms.authToken;
|
|
125
127
|
setBadge("sms",
|
|
126
|
-
sms.enabled &&
|
|
127
|
-
sms.enabled &&
|
|
128
|
+
sms.enabled && smsReady ? "ok" : smsReady ? "warn" : sms.accountSid ? "warn" : "idle",
|
|
129
|
+
sms.enabled && smsReady ? "Configured" : smsReady ? "Disabled" : sms.accountSid ? "Incomplete" : "Not configured");
|
|
128
130
|
|
|
129
131
|
// DND badge: "Active" (red), "Scheduled" (warn), or "Off" (idle)
|
|
130
132
|
const dnd = config.dnd ?? {};
|
|
@@ -187,7 +189,8 @@ async function loadVoices() {
|
|
|
187
189
|
for (const v of byLocale[locale].sort((a, b) => a.shortName.localeCompare(b.shortName))) {
|
|
188
190
|
const opt = document.createElement("option");
|
|
189
191
|
opt.value = v.shortName;
|
|
190
|
-
|
|
192
|
+
const name = v.shortName.replace(locale + "-", "").replace(/Neural$/, "").replace(/Multilingual$/, " (Multi)");
|
|
193
|
+
opt.textContent = `${name} · ${v.gender}`;
|
|
191
194
|
if (v.shortName === current) opt.selected = true;
|
|
192
195
|
og.appendChild(opt);
|
|
193
196
|
}
|
|
@@ -203,6 +206,20 @@ async function saveEmail() {
|
|
|
203
206
|
clearDirty("email");
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
// Standalone enable-toggle handlers: persist immediately without requiring a
|
|
210
|
+
// Save-button click. Credentials still need the explicit Save flow, but the
|
|
211
|
+
// on/off switch auto-persists so users aren't left wondering why their
|
|
212
|
+
// toggle "snapped back" after a reload.
|
|
213
|
+
async function toggleTelegramEnabled() {
|
|
214
|
+
await patch({ telegram: { enabled: $("telegram-enabled").checked } });
|
|
215
|
+
}
|
|
216
|
+
async function toggleEmailEnabled() {
|
|
217
|
+
await patch({ email: { enabled: $("email-enabled").checked } });
|
|
218
|
+
}
|
|
219
|
+
async function toggleSmsEnabled() {
|
|
220
|
+
await patch({ sms: { enabled: $("sms-enabled").checked } });
|
|
221
|
+
}
|
|
222
|
+
|
|
206
223
|
async function saveTelegram() {
|
|
207
224
|
await patch({
|
|
208
225
|
telegram: {
|
|
@@ -549,11 +566,28 @@ function parseLogEntry(raw) {
|
|
|
549
566
|
return { ts: m[1], client: m[2] || null, dir: m[3], channel: m[4], msg: m[5] };
|
|
550
567
|
}
|
|
551
568
|
|
|
569
|
+
let logFilterClient = "";
|
|
570
|
+
|
|
571
|
+
function selectLogFilter(clientId) {
|
|
572
|
+
logFilterClient = clientId;
|
|
573
|
+
document.querySelectorAll("#session-pills .pill").forEach(p => {
|
|
574
|
+
p.classList.toggle("pill-active", (p.dataset.client || "") === clientId);
|
|
575
|
+
});
|
|
576
|
+
// Re-apply hidden class to all entries based on the new filter.
|
|
577
|
+
document.querySelectorAll("#log-panel .log-entry").forEach(el => {
|
|
578
|
+
const c = el.dataset.client || "";
|
|
579
|
+
el.style.display = (!clientId || c === clientId || (!c && clientId === "")) ? "" : "none";
|
|
580
|
+
});
|
|
581
|
+
const panel = $("log-panel");
|
|
582
|
+
panel.scrollTop = panel.scrollHeight;
|
|
583
|
+
}
|
|
584
|
+
|
|
552
585
|
function renderLogEntry(raw) {
|
|
553
586
|
const panel = $("log-panel");
|
|
554
587
|
const p = parseLogEntry(raw);
|
|
555
588
|
const el = document.createElement("div");
|
|
556
589
|
el.className = "log-entry";
|
|
590
|
+
el.dataset.client = (p && p.client) ? p.client : "";
|
|
557
591
|
|
|
558
592
|
if (p) {
|
|
559
593
|
const ts = new Date(p.ts).toLocaleTimeString([], { hour12: false });
|
|
@@ -571,11 +605,56 @@ function renderLogEntry(raw) {
|
|
|
571
605
|
el.innerHTML = `<span class="log-msg">${raw.replace(/</g,"<")}</span>`;
|
|
572
606
|
}
|
|
573
607
|
|
|
608
|
+
if (logFilterClient && el.dataset.client !== logFilterClient) {
|
|
609
|
+
el.style.display = "none";
|
|
610
|
+
}
|
|
574
611
|
const atBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 20;
|
|
575
612
|
panel.appendChild(el);
|
|
576
613
|
if (atBottom) panel.scrollTop = panel.scrollHeight;
|
|
577
614
|
}
|
|
578
615
|
|
|
616
|
+
async function refreshSessions() {
|
|
617
|
+
try {
|
|
618
|
+
const res = await fetch("/api/sessions");
|
|
619
|
+
if (!res.ok) return;
|
|
620
|
+
const { sessions } = await res.json();
|
|
621
|
+
const bar = $("session-pills");
|
|
622
|
+
// Build a set of desired pills (keyed by clientId). Keep the "All" pill.
|
|
623
|
+
const existing = new Map();
|
|
624
|
+
bar.querySelectorAll(".pill").forEach(p => existing.set(p.dataset.client || "", p));
|
|
625
|
+
const desired = new Set([""]);
|
|
626
|
+
for (const s of sessions) desired.add(s.clientId);
|
|
627
|
+
// Remove pills for disconnected sessions.
|
|
628
|
+
for (const [id, el] of existing) {
|
|
629
|
+
if (!desired.has(id)) el.remove();
|
|
630
|
+
}
|
|
631
|
+
// Add pills for new sessions.
|
|
632
|
+
for (const s of sessions) {
|
|
633
|
+
if (!existing.has(s.clientId)) {
|
|
634
|
+
const btn = document.createElement("button");
|
|
635
|
+
btn.className = "pill";
|
|
636
|
+
btn.dataset.client = s.clientId;
|
|
637
|
+
btn.textContent = s.tag ? `@${s.tag}` : s.clientId;
|
|
638
|
+
btn.title = [s.clientName, s.host].filter(Boolean).join(" · ");
|
|
639
|
+
btn.onclick = () => selectLogFilter(s.clientId);
|
|
640
|
+
bar.appendChild(btn);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// If the currently-selected client disconnected, fall back to "All".
|
|
644
|
+
if (logFilterClient && !desired.has(logFilterClient)) {
|
|
645
|
+
selectLogFilter("");
|
|
646
|
+
} else {
|
|
647
|
+
// Re-sync active state (in case pills were re-added).
|
|
648
|
+
bar.querySelectorAll(".pill").forEach(p => {
|
|
649
|
+
p.classList.toggle("pill-active", (p.dataset.client || "") === logFilterClient);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
} catch { /* ignore */ }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
setInterval(refreshSessions, 3000);
|
|
656
|
+
refreshSessions();
|
|
657
|
+
|
|
579
658
|
function clearLog() {
|
|
580
659
|
$("log-panel").innerHTML = "";
|
|
581
660
|
}
|
package/ui/public/index.html
CHANGED
|
@@ -63,8 +63,9 @@
|
|
|
63
63
|
<span class="toggle-lbl">Speak notification</span>
|
|
64
64
|
<button class="btn btn-sm btn-ghost" onclick="testTts()">Test voice</button>
|
|
65
65
|
</div>
|
|
66
|
-
<div id="tts-voice-row" class="
|
|
67
|
-
<
|
|
66
|
+
<div id="tts-voice-row" class="fg" style="margin-top:8px; display:none">
|
|
67
|
+
<label>Voice</label>
|
|
68
|
+
<select id="desktop-tts-voice" onchange="saveDesktop()"></select>
|
|
68
69
|
</div>
|
|
69
70
|
<span id="os-hint" class="os-tag hidden"></span>
|
|
70
71
|
</div>
|
|
@@ -86,7 +87,7 @@
|
|
|
86
87
|
<div id="gmail-connected-state" class="hidden">
|
|
87
88
|
<div class="actions">
|
|
88
89
|
<label class="toggle-wrap">
|
|
89
|
-
<input type="checkbox" id="email-enabled"
|
|
90
|
+
<input type="checkbox" id="email-enabled" onchange="toggleEmailEnabled()">
|
|
90
91
|
<span class="toggle"></span>
|
|
91
92
|
</label>
|
|
92
93
|
<span class="toggle-lbl">Enable</span>
|
|
@@ -133,7 +134,7 @@
|
|
|
133
134
|
<div class="card-body">
|
|
134
135
|
<div class="actions">
|
|
135
136
|
<label class="toggle-wrap">
|
|
136
|
-
<input type="checkbox" id="telegram-enabled"
|
|
137
|
+
<input type="checkbox" id="telegram-enabled" onchange="toggleTelegramEnabled()">
|
|
137
138
|
<span class="toggle"></span>
|
|
138
139
|
</label>
|
|
139
140
|
<span class="toggle-lbl">Enable</span>
|
|
@@ -174,7 +175,7 @@
|
|
|
174
175
|
<div class="card-body">
|
|
175
176
|
<div class="actions">
|
|
176
177
|
<label class="toggle-wrap">
|
|
177
|
-
<input type="checkbox" id="sms-enabled"
|
|
178
|
+
<input type="checkbox" id="sms-enabled" onchange="toggleSmsEnabled()">
|
|
178
179
|
<span class="toggle"></span>
|
|
179
180
|
</label>
|
|
180
181
|
<span class="toggle-lbl">Enable</span>
|
|
@@ -302,6 +303,9 @@
|
|
|
302
303
|
<span class="dot dot-ok" id="log-dot" style="display:inline-block"></span>
|
|
303
304
|
Activity Log
|
|
304
305
|
</span>
|
|
306
|
+
<div class="session-pills" id="session-pills">
|
|
307
|
+
<button class="pill pill-active" data-client="" onclick="selectLogFilter('')">All</button>
|
|
308
|
+
</div>
|
|
305
309
|
<button class="btn btn-sm btn-ghost" onclick="clearLog()">Clear</button>
|
|
306
310
|
</div>
|
|
307
311
|
<div class="log-panel" id="log-panel"></div>
|
package/ui/public/style.css
CHANGED
|
@@ -102,11 +102,11 @@ main {
|
|
|
102
102
|
flex: 1;
|
|
103
103
|
min-height: 0;
|
|
104
104
|
height: 100%;
|
|
105
|
-
overflow:
|
|
106
|
-
display:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
align-content:
|
|
105
|
+
overflow: auto;
|
|
106
|
+
display: grid;
|
|
107
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
108
|
+
grid-auto-rows: min-content;
|
|
109
|
+
align-content: start;
|
|
110
110
|
gap: 12px;
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -204,11 +204,40 @@ main {
|
|
|
204
204
|
letter-spacing: .5px;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
.session-pills {
|
|
208
|
+
display: flex;
|
|
209
|
+
align-items: center;
|
|
210
|
+
gap: 6px;
|
|
211
|
+
flex: 1;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
flex-wrap: wrap;
|
|
214
|
+
overflow: hidden;
|
|
215
|
+
}
|
|
216
|
+
.pill {
|
|
217
|
+
font: inherit;
|
|
218
|
+
font-size: 11px;
|
|
219
|
+
padding: 3px 10px;
|
|
220
|
+
border-radius: 12px;
|
|
221
|
+
border: 1px solid var(--border);
|
|
222
|
+
background: transparent;
|
|
223
|
+
color: var(--text-2);
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
transition: background .15s, color .15s, border-color .15s;
|
|
226
|
+
white-space: nowrap;
|
|
227
|
+
}
|
|
228
|
+
.pill:hover { border-color: var(--accent); color: var(--text); }
|
|
229
|
+
.pill-active {
|
|
230
|
+
background: var(--accent);
|
|
231
|
+
border-color: var(--accent);
|
|
232
|
+
color: #fff;
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
208
236
|
|
|
209
237
|
.card {
|
|
210
|
-
width:
|
|
238
|
+
width: auto;
|
|
211
239
|
max-width: 100%;
|
|
240
|
+
min-width: 0;
|
|
212
241
|
background: var(--surface);
|
|
213
242
|
border: 2px solid #4a4a5a;
|
|
214
243
|
border-radius: var(--r);
|
|
@@ -362,6 +391,27 @@ input[type="time"] {
|
|
|
362
391
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
|
363
392
|
}
|
|
364
393
|
input::placeholder { color: #cdcdda; opacity: 1; }
|
|
394
|
+
|
|
395
|
+
select {
|
|
396
|
+
width: 100%;
|
|
397
|
+
max-width: 100%;
|
|
398
|
+
box-sizing: border-box;
|
|
399
|
+
padding: 8px 11px;
|
|
400
|
+
border: 2px solid #7a7a95;
|
|
401
|
+
border-radius: 7px;
|
|
402
|
+
font-size: 14px;
|
|
403
|
+
font-weight: 500;
|
|
404
|
+
color: #ffffff;
|
|
405
|
+
background: #55556a;
|
|
406
|
+
outline: none;
|
|
407
|
+
transition: border-color .15s, box-shadow .15s;
|
|
408
|
+
color-scheme: dark;
|
|
409
|
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
|
|
410
|
+
cursor: pointer;
|
|
411
|
+
text-overflow: ellipsis;
|
|
412
|
+
}
|
|
413
|
+
select:hover { border-color: #9a9ab5; }
|
|
414
|
+
select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(124,109,250,.15); }
|
|
365
415
|
input[type="text"]:hover,
|
|
366
416
|
input[type="email"]:hover,
|
|
367
417
|
input[type="tel"]:hover,
|