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 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
- // Acknowledge receipt so user knows the message was queued.
867
- // Wording is deliberately about *delivery*, not processing: the
868
- // agent might be tailing the SSE stream (sees it immediately) or
869
- // might only check on its next poll/notify call.
870
- const ackText = tag
871
- ? `📬 Delivered to @${tag}.`
872
- : `📬 Delivered. The agent will process it on its next check-in.`;
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', or via the /api/inbox/stream SSE), reply
967
- to them THROUGH 'notify' so the reply actually reaches them — not just in
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
- 6. The 'get_idle_*' and 'get_dnd_status' tools are informational. You can
971
- inspect them if you want to explain a delivery decision, but they are NOT
972
- required pre-flights the server gates server-side.
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 before 'notify' or 'ask' to decide whether to skip (user is active) " +
1079
- "or fire (user stepped away). Returns -1 if idle detection is unsupported on " +
1080
- "this platform in that case, proceed without gating (fail-open).", {}, async () => {
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
- "You don't need to pre-flight idle checks; just call 'notify'.", {}, async () => {
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
- "Clients can use this to short-circuit before calling 'notify'.", {}, async () => {
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 clientId = sessionTag ?? `sess-${newSessionId.slice(0, 8)}`;
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
- await createMcpServer(clientId, sessionTag).connect(transport);
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.6",
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 && tg.token && tg.chatId ? "ok" : tg.token ? "warn" : "idle",
122
- tg.enabled && tg.token && tg.chatId ? "Configured" : tg.token ? "Incomplete" : "Not configured");
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 && sms.accountSid && sms.authToken ? "ok" : sms.accountSid ? "warn" : "idle",
127
- sms.enabled && sms.accountSid && sms.authToken ? "Configured" : sms.accountSid ? "Incomplete" : "Not configured");
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
- opt.textContent = `${v.shortName.replace(locale + "-", "")} (${v.gender})`;
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,"&lt;")}</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
  }
@@ -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="actions" style="margin-top:6px; display:none">
67
- <select id="desktop-tts-voice" onchange="saveDesktop()" style="flex:1; min-width:0"></select>
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" oninput="markDirty('email')">
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" oninput="markDirty('telegram')">
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" oninput="markDirty('sms')">
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>
@@ -102,11 +102,11 @@ main {
102
102
  flex: 1;
103
103
  min-height: 0;
104
104
  height: 100%;
105
- overflow: hidden;
106
- display: flex;
107
- flex-direction: column;
108
- flex-wrap: wrap;
109
- align-content: flex-start;
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: 280px;
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,