omni-notify-mcp 1.1.7 → 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,40 @@ 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.
969
1025
 
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.
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.
973
1048
 
974
1049
  7. If your tool call fails with "MCP server not connected" / "transport
975
1050
  closed" / similar — the SERVER IS ALMOST CERTAINLY FINE. Other clients are
@@ -1120,23 +1195,25 @@ function createMcpServer(clientId, sessionTag) {
1120
1195
  };
1121
1196
  });
1122
1197
  server.tool("get_idle_seconds", "Returns the number of seconds since the user's last keyboard/mouse input. " +
1123
- "Call this before 'notify' or 'ask' to decide whether to skip (user is active) " +
1124
- "or fire (user stepped away). Returns -1 if idle detection is unsupported on " +
1125
- "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 () => {
1126
1203
  const secs = getOsIdleSeconds();
1127
- return { content: [{ type: "text", text: String(secs) }] };
1204
+ return { content: [{ type: "text", text: appendInbox(String(secs), sessionTag, clientId) }] };
1128
1205
  });
1129
1206
  server.tool("get_idle_config", "Returns the server's idle gating policy: { enabled, thresholdSeconds, alwaysDesktopWhenActive }. " +
1130
1207
  "Informational only — the server gates internally on every notify call. " +
1131
- "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 () => {
1132
1209
  const cfg = loadConfig();
1133
1210
  const idle = cfg.idle ?? { enabled: true, thresholdSeconds: 120, alwaysDesktopWhenActive: true };
1134
- return { content: [{ type: "text", text: JSON.stringify(idle) }] };
1211
+ return { content: [{ type: "text", text: appendInbox(JSON.stringify(idle), sessionTag, clientId) }] };
1135
1212
  });
1136
1213
  server.tool("get_dnd_status", "Returns the current DND state: " +
1137
1214
  "{ active: boolean, reason: 'manual' | 'schedule' | 'off' }. " +
1138
1215
  "When active, the server will suppress delivery for priority < high. " +
1139
- "Clients can use this to short-circuit before calling 'notify'.", {}, async () => {
1216
+ "Also drains pending inbox messages — safe to use as a heartbeat.", {}, async () => {
1140
1217
  const cfg = loadConfig();
1141
1218
  const active = isDndActive(cfg);
1142
1219
  let reason = "off";
@@ -1144,31 +1221,106 @@ function createMcpServer(clientId, sessionTag) {
1144
1221
  reason = cfg.dnd?.enabled ? "manual" : "schedule";
1145
1222
  }
1146
1223
  return {
1147
- content: [{ type: "text", text: JSON.stringify({ active, reason }) }],
1224
+ content: [{ type: "text", text: appendInbox(JSON.stringify({ active, reason }), sessionTag, clientId) }],
1148
1225
  };
1149
1226
  });
1150
1227
  return server;
1151
1228
  }
1152
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
+ }
1153
1261
  app.all("/mcp", async (req, res) => {
1154
1262
  const existingSessionId = req.headers["mcp-session-id"];
1155
1263
  if (existingSessionId && httpTransports[existingSessionId]) {
1156
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
+ });
1157
1293
  return;
1158
1294
  }
1159
1295
  const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
1160
1296
  const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
1161
1297
  const newSessionId = randomUUID();
1162
- 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)}`);
1163
1304
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
1164
1305
  transport.onclose = () => {
1165
- if (transport.sessionId)
1306
+ if (transport.sessionId) {
1166
1307
  delete httpTransports[transport.sessionId];
1308
+ delete sessions[transport.sessionId];
1309
+ }
1167
1310
  };
1168
- 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;
1169
1316
  await transport.handleRequest(req, res, req.body);
1170
- if (transport.sessionId)
1317
+ if (transport.sessionId) {
1171
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
+ }
1172
1324
  });
1173
1325
  // ── Start ─────────────────────────────────────────────────────────────────────
1174
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.7",
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
@@ -206,6 +206,20 @@ async function saveEmail() {
206
206
  clearDirty("email");
207
207
  }
208
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
+
209
223
  async function saveTelegram() {
210
224
  await patch({
211
225
  telegram: {
@@ -552,11 +566,28 @@ function parseLogEntry(raw) {
552
566
  return { ts: m[1], client: m[2] || null, dir: m[3], channel: m[4], msg: m[5] };
553
567
  }
554
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
+
555
585
  function renderLogEntry(raw) {
556
586
  const panel = $("log-panel");
557
587
  const p = parseLogEntry(raw);
558
588
  const el = document.createElement("div");
559
589
  el.className = "log-entry";
590
+ el.dataset.client = (p && p.client) ? p.client : "";
560
591
 
561
592
  if (p) {
562
593
  const ts = new Date(p.ts).toLocaleTimeString([], { hour12: false });
@@ -574,11 +605,56 @@ function renderLogEntry(raw) {
574
605
  el.innerHTML = `<span class="log-msg">${raw.replace(/</g,"&lt;")}</span>`;
575
606
  }
576
607
 
608
+ if (logFilterClient && el.dataset.client !== logFilterClient) {
609
+ el.style.display = "none";
610
+ }
577
611
  const atBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 20;
578
612
  panel.appendChild(el);
579
613
  if (atBottom) panel.scrollTop = panel.scrollHeight;
580
614
  }
581
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
+
582
658
  function clearLog() {
583
659
  $("log-panel").innerHTML = "";
584
660
  }
@@ -87,7 +87,7 @@
87
87
  <div id="gmail-connected-state" class="hidden">
88
88
  <div class="actions">
89
89
  <label class="toggle-wrap">
90
- <input type="checkbox" id="email-enabled" oninput="markDirty('email')">
90
+ <input type="checkbox" id="email-enabled" onchange="toggleEmailEnabled()">
91
91
  <span class="toggle"></span>
92
92
  </label>
93
93
  <span class="toggle-lbl">Enable</span>
@@ -134,7 +134,7 @@
134
134
  <div class="card-body">
135
135
  <div class="actions">
136
136
  <label class="toggle-wrap">
137
- <input type="checkbox" id="telegram-enabled" oninput="markDirty('telegram')">
137
+ <input type="checkbox" id="telegram-enabled" onchange="toggleTelegramEnabled()">
138
138
  <span class="toggle"></span>
139
139
  </label>
140
140
  <span class="toggle-lbl">Enable</span>
@@ -175,7 +175,7 @@
175
175
  <div class="card-body">
176
176
  <div class="actions">
177
177
  <label class="toggle-wrap">
178
- <input type="checkbox" id="sms-enabled" oninput="markDirty('sms')">
178
+ <input type="checkbox" id="sms-enabled" onchange="toggleSmsEnabled()">
179
179
  <span class="toggle"></span>
180
180
  </label>
181
181
  <span class="toggle-lbl">Enable</span>
@@ -303,6 +303,9 @@
303
303
  <span class="dot dot-ok" id="log-dot" style="display:inline-block"></span>
304
304
  Activity Log
305
305
  </span>
306
+ <div class="session-pills" id="session-pills">
307
+ <button class="pill pill-active" data-client="" onclick="selectLogFilter('')">All</button>
308
+ </div>
306
309
  <button class="btn btn-sm btn-ghost" onclick="clearLog()">Clear</button>
307
310
  </div>
308
311
  <div class="log-panel" id="log-panel"></div>
@@ -204,6 +204,34 @@ 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 {