omni-notify-mcp 1.2.0 → 1.2.3
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/dist/channels/desktop.js +1 -1
- package/dist/index.js +11 -1
- package/dist/ui/server.js +114 -25
- package/package.json +1 -1
- package/ui/public/app.js +35 -8
- package/ui/public/style.css +12 -0
package/dist/channels/desktop.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function speak(text, voice = DEFAULT_TTS_VOICE) {
|
|
|
36
36
|
if (process.platform === "win32") {
|
|
37
37
|
spawn("powershell", [
|
|
38
38
|
"-NoProfile", "-Command",
|
|
39
|
-
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -
|
|
39
|
+
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
|
|
40
40
|
], { windowsHide: true, stdio: "ignore" });
|
|
41
41
|
}
|
|
42
42
|
else if (process.platform === "darwin") {
|
package/dist/index.js
CHANGED
|
@@ -171,7 +171,17 @@ const server = new McpServer({ name: "notify-mcp", version: "1.2.0" }, {
|
|
|
171
171
|
instructions: "This is the stdio bridge for notify-mcp. It pushes unsolicited user " +
|
|
172
172
|
"messages to the agent via `notifications/claude/channel` when the host " +
|
|
173
173
|
"supports Channels (Claude Code v2.1.80+). Otherwise call `wait_for_inbox` " +
|
|
174
|
-
"as a long-poll to reliably receive user messages as tool results
|
|
174
|
+
"as a long-poll to reliably receive user messages as tool results.\n\n" +
|
|
175
|
+
"ALWAYS call `notify` when: (a) a task that took >60s of wall-clock time " +
|
|
176
|
+
"just finished (success or failure), (b) you have a question or need a " +
|
|
177
|
+
"decision, (c) something important happened the user needs to know right " +
|
|
178
|
+
"now. Idle/DND gating is handled server-side — fire notify and let the " +
|
|
179
|
+
"server decide routing. Err on the side of notifying: a wrong-call gets " +
|
|
180
|
+
"silently downgraded by idle gating; a missed notify costs the user hours.\n\n" +
|
|
181
|
+
"ALWAYS echo the COMPLETE, UNTRUNCATED message in your chat output — never " +
|
|
182
|
+
"shorten it with '…' or a summary. NEVER mention delivery channels (Telegram, " +
|
|
183
|
+
"SMS, desktop, etc.) or echo 'Sent via: …' — those are server internals. " +
|
|
184
|
+
"Say 'notif' or 'notification' if you need to refer to the act of notifying.",
|
|
175
185
|
});
|
|
176
186
|
// Thin proxy: forward a tool call to the HTTP server and return its content
|
|
177
187
|
// block array verbatim. Error shape matches what the SDK expects from tool
|
package/dist/ui/server.js
CHANGED
|
@@ -181,7 +181,7 @@ async function speakText(text, voice) {
|
|
|
181
181
|
if (process.platform === "win32") {
|
|
182
182
|
spawn("powershell", [
|
|
183
183
|
"-NoProfile", "-Command",
|
|
184
|
-
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); Start-Sleep -
|
|
184
|
+
`Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $done = $false; Register-ObjectEvent $p MediaEnded -Action { $script:done = $true } | Out-Null; $p.Open([uri]'${audioFilePath.replace(/\\/g, "\\\\")}'); $p.Play(); while (-not $done) { Start-Sleep -Milliseconds 200 }`,
|
|
185
185
|
], { windowsHide: true, stdio: "ignore" });
|
|
186
186
|
}
|
|
187
187
|
else if (process.platform === "darwin") {
|
|
@@ -558,11 +558,29 @@ app.get("/api/sessions", (_req, res) => {
|
|
|
558
558
|
tag: s.tag,
|
|
559
559
|
clientName: s.clientName,
|
|
560
560
|
clientVersion: s.clientVersion,
|
|
561
|
+
workspaceName: s.workspaceName,
|
|
561
562
|
host: s.host,
|
|
562
563
|
connectedAt: s.connectedAt,
|
|
564
|
+
lastSeen: s.lastSeen,
|
|
563
565
|
}));
|
|
564
566
|
res.json({ sessions: list });
|
|
565
567
|
});
|
|
568
|
+
app.delete("/api/sessions/:clientId", (req, res) => {
|
|
569
|
+
const { clientId } = req.params;
|
|
570
|
+
const entry = Object.entries(sessions).find(([, m]) => m.clientId === clientId);
|
|
571
|
+
if (!entry) {
|
|
572
|
+
res.status(404).json({ error: "not found" });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const [sessionId] = entry;
|
|
576
|
+
try {
|
|
577
|
+
httpTransports[sessionId]?.close();
|
|
578
|
+
}
|
|
579
|
+
catch { /* ignore */ }
|
|
580
|
+
delete httpTransports[sessionId];
|
|
581
|
+
delete sessions[sessionId];
|
|
582
|
+
res.json({ ok: true });
|
|
583
|
+
});
|
|
566
584
|
app.get("/api/logs", (req, res) => {
|
|
567
585
|
res.setHeader("Content-Type", "text/event-stream");
|
|
568
586
|
res.setHeader("Cache-Control", "no-cache");
|
|
@@ -1116,12 +1134,37 @@ channels are in use — just call 'notify' or 'ask' with a generic message.
|
|
|
1116
1134
|
|
|
1117
1135
|
BEHAVIORAL RULES for every client that connects:
|
|
1118
1136
|
|
|
1119
|
-
1.
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1137
|
+
1. ALWAYS call 'notify' in these three situations — idle or not, DND or not,
|
|
1138
|
+
the server decides routing, you decide whether to fire:
|
|
1139
|
+
|
|
1140
|
+
(a) LONG PROCESSING FINISHED. Any single task that took more than ~60
|
|
1141
|
+
seconds of wall-clock time (long build, test run, backtest, migration,
|
|
1142
|
+
big refactor, multi-step plan) gets a 'notify' the moment it completes
|
|
1143
|
+
— success OR failure. Rule of thumb: if the user could have reasonably
|
|
1144
|
+
walked away to grab coffee while you ran, they need a ping on the way
|
|
1145
|
+
back. Don't try to guess whether they were watching. Just notify.
|
|
1146
|
+
|
|
1147
|
+
(b) YOU HAVE A QUESTION OR NEED A DECISION. Any time you're about to ask
|
|
1148
|
+
the user something — "should I delete these?", "which branch?",
|
|
1149
|
+
"proceed with plan B?" — fire 'notify' (or 'ask' for blocking
|
|
1150
|
+
two-way). Silent questions in the terminal get missed; a notification
|
|
1151
|
+
does not.
|
|
1152
|
+
|
|
1153
|
+
(c) SOMETHING IMPORTANT HAPPENED that the user needs to know about right
|
|
1154
|
+
now. Examples: a test suddenly failed after being green, a destructive
|
|
1155
|
+
operation is about to run, you found a security issue, a deploy
|
|
1156
|
+
succeeded, a production service looks degraded, you hit an
|
|
1157
|
+
unrecoverable error. When in doubt on importance, ERR ON THE SIDE OF
|
|
1158
|
+
NOTIFYING — the server's idle gating will automatically downgrade a
|
|
1159
|
+
mis-judged 'normal' to a silent desktop banner if the user is active,
|
|
1160
|
+
so the cost of over-notifying is near zero. The cost of missing a
|
|
1161
|
+
real event is that the user finds out 4 hours later.
|
|
1162
|
+
|
|
1163
|
+
The SERVER handles all routing (DND, idle threshold, channel selection,
|
|
1164
|
+
priority escalation). You do NOT need to pre-flight with
|
|
1165
|
+
'get_idle_seconds' before these three triggers — fire 'notify' and let
|
|
1166
|
+
the server decide. get_idle_seconds is the HEARTBEAT primitive (rule 6),
|
|
1167
|
+
not a gate on legitimate milestones.
|
|
1125
1168
|
|
|
1126
1169
|
2. Use priority correctly:
|
|
1127
1170
|
- 'low' = email only — for low-stakes status (background completion).
|
|
@@ -1130,13 +1173,18 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1130
1173
|
Use ONLY for catastrophic findings or decisions that block
|
|
1131
1174
|
progress. Misuse will train the user to ignore your notifs.
|
|
1132
1175
|
|
|
1133
|
-
3. Echo the
|
|
1134
|
-
as sending it through 'notify'. The user may be reading the
|
|
1135
|
-
directly; don't rely on them checking their phone / email.
|
|
1176
|
+
3. Echo the COMPLETE, UNTRUNCATED message body in your own chat / conversation
|
|
1177
|
+
output as well as sending it through 'notify'. The user may be reading the
|
|
1178
|
+
terminal directly; don't rely on them checking their phone / email. Do NOT
|
|
1179
|
+
shorten, summarise, or cut off the message with "…" in your chat output —
|
|
1180
|
+
show every word exactly as sent.
|
|
1136
1181
|
|
|
1137
1182
|
4. The message body should be channel-agnostic. Never name 'Telegram', 'SMS',
|
|
1138
|
-
'email', etc. in your messages
|
|
1139
|
-
user has already configured
|
|
1183
|
+
'email', 'desktop', etc. in your messages or in your chat output — those are
|
|
1184
|
+
server delivery details the user has already configured and the client has
|
|
1185
|
+
no business surfacing. Do NOT echo "Sent via: <channel list>" or any
|
|
1186
|
+
variant of it. Just say 'notif' or 'notification' if you need to refer to
|
|
1187
|
+
the act of notifying.
|
|
1140
1188
|
|
|
1141
1189
|
5. When the user sends you an unsolicited message (visible as INBOX items in
|
|
1142
1190
|
the 'notify' response, via 'poll', via 'wait_for_inbox', via
|
|
@@ -1231,7 +1279,9 @@ BEHAVIORAL RULES for every client that connects:
|
|
|
1231
1279
|
MCP client is broken, you owe them the bypass — not an excuse.
|
|
1232
1280
|
`.trim();
|
|
1233
1281
|
function createMcpServer(clientId, sessionTag) {
|
|
1234
|
-
const
|
|
1282
|
+
const identity = sessionTag ? `@${sessionTag}` : clientId;
|
|
1283
|
+
const identityLine = `\nYOUR SESSION IDENTITY: "${identity}" — use this as your prefix in all notify replies (e.g. "[${identity}] done with build").\n`;
|
|
1284
|
+
const server = new McpServer({ name: "notify-mcp", version: "1.0.0" }, { instructions: identityLine + MCP_INSTRUCTIONS });
|
|
1235
1285
|
server.tool("notify", "Send a notification to the user. Delivery channels and DND are server-configured. " +
|
|
1236
1286
|
"Before calling, check get_idle_seconds against get_idle_config.thresholdSeconds; " +
|
|
1237
1287
|
"skip the call if the user is active (unless priority='high'). " +
|
|
@@ -1475,14 +1525,16 @@ function sessionDisplay(s) {
|
|
|
1475
1525
|
return s.tag ? `@${s.tag}` : s.clientId;
|
|
1476
1526
|
}
|
|
1477
1527
|
app.all("/mcp", async (req, res) => {
|
|
1528
|
+
console.log("[debug-url]", req.method, req.url, "query:", JSON.stringify(req.query), "ua:", req.headers["user-agent"]);
|
|
1478
1529
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
1479
1530
|
if (existingSessionId && httpTransports[existingSessionId]) {
|
|
1480
|
-
|
|
1531
|
+
const transport = httpTransports[existingSessionId];
|
|
1532
|
+
await transport.handleRequest(req, res, req.body);
|
|
1481
1533
|
// Lazy-populate clientInfo after initialize lands on an existing session.
|
|
1482
1534
|
const meta = sessions[existingSessionId];
|
|
1483
1535
|
if (meta)
|
|
1484
1536
|
meta.lastSeen = Date.now();
|
|
1485
|
-
const mcpServer = httpTransports[existingSessionId]
|
|
1537
|
+
const mcpServer = httpTransports[existingSessionId]?.__mcpServer;
|
|
1486
1538
|
if (meta && !meta.clientName && mcpServer?.getClientVersion) {
|
|
1487
1539
|
try {
|
|
1488
1540
|
const info = mcpServer.getClientVersion();
|
|
@@ -1495,12 +1547,19 @@ app.all("/mcp", async (req, res) => {
|
|
|
1495
1547
|
}
|
|
1496
1548
|
return;
|
|
1497
1549
|
}
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
|
|
1550
|
+
// Auto-reconnect path. If the client presents a session id we don't know
|
|
1551
|
+
// about AND the request body is a fresh `initialize`, adopt the stale id
|
|
1552
|
+
// instead of 404-ing. This covers the "server was restarted while Claude
|
|
1553
|
+
// Code was open" case: clients that cache the session id (claude-code#27142)
|
|
1554
|
+
// would otherwise stay ghost until the human manually reloaded the window.
|
|
1555
|
+
// A non-initialize request with an unknown id still gets 404 — the client
|
|
1556
|
+
// is expected to reinitialize in response.
|
|
1557
|
+
const bodyIsInitialize = req.method === "POST" &&
|
|
1558
|
+
req.body &&
|
|
1559
|
+
(Array.isArray(req.body)
|
|
1560
|
+
? req.body.some((m) => m?.method === "initialize")
|
|
1561
|
+
: req.body.method === "initialize");
|
|
1562
|
+
if (existingSessionId && !bodyIsInitialize) {
|
|
1504
1563
|
res.status(404).json({
|
|
1505
1564
|
jsonrpc: "2.0",
|
|
1506
1565
|
error: { code: -32000, message: "Session not found — reinitialize" },
|
|
@@ -1510,13 +1569,40 @@ app.all("/mcp", async (req, res) => {
|
|
|
1510
1569
|
}
|
|
1511
1570
|
const rawTag = typeof req.query.tag === "string" ? req.query.tag : undefined;
|
|
1512
1571
|
const sessionTag = rawTag?.toLowerCase().replace(/[^a-z0-9_-]/g, "") || undefined;
|
|
1513
|
-
|
|
1572
|
+
// If the client brought a stale id on an initialize, reuse it so the client
|
|
1573
|
+
// never has to swap ids. Otherwise mint a fresh one.
|
|
1574
|
+
const newSessionId = existingSessionId ?? randomUUID();
|
|
1514
1575
|
const host = (req.socket.remoteAddress || "").replace(/^::ffff:/, "") || undefined;
|
|
1515
1576
|
const port = req.socket.remotePort;
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
const
|
|
1577
|
+
// Pull clientInfo and workspace from the initialize body immediately so the
|
|
1578
|
+
// pill shows a readable name from the start.
|
|
1579
|
+
const initBody = Array.isArray(req.body)
|
|
1580
|
+
? req.body.find((m) => m?.method === "initialize")
|
|
1581
|
+
: req.body;
|
|
1582
|
+
const earlyClientName = initBody?.params?.clientInfo?.name;
|
|
1583
|
+
// Prefer the workspace folder name (e.g. "AlphaWave", "notify-mcp-src") over
|
|
1584
|
+
// the generic client name ("claude-code"). workspaceFolders[0].name is set by
|
|
1585
|
+
// Claude Code and Cursor; rootUri is the fallback.
|
|
1586
|
+
const workspaceFolders = initBody?.params?.workspaceFolders;
|
|
1587
|
+
const rootUri = initBody?.params?.rootUri ?? initBody?.params?.root_uri;
|
|
1588
|
+
const workspaceName = workspaceFolders?.[0]?.name ||
|
|
1589
|
+
(rootUri ? rootUri.replace(/\\/g, "/").split("/").filter(Boolean).pop() : undefined);
|
|
1590
|
+
// Build a distinguishable client id: tag wins if set; workspace name next;
|
|
1591
|
+
// then clientInfo.name; otherwise use host+port. If the base id is already
|
|
1592
|
+
// taken, append -2, -3, … so two windows on the same project still show up.
|
|
1593
|
+
const baseId = sessionTag
|
|
1594
|
+
?? workspaceName
|
|
1595
|
+
?? earlyClientName
|
|
1519
1596
|
?? (host && port ? `${host === "127.0.0.1" || host === "::1" ? "local" : host}:${port}` : `sess-${newSessionId.slice(0, 8)}`);
|
|
1597
|
+
// Exclude the session being re-adopted from the "taken" set — it's about to
|
|
1598
|
+
// be replaced, so its old clientId should be available for reuse.
|
|
1599
|
+
const adoptingId = existingSessionId && bodyIsInitialize ? existingSessionId : undefined;
|
|
1600
|
+
const takenIds = new Set(Object.entries(sessions)
|
|
1601
|
+
.filter(([sid]) => sid !== adoptingId)
|
|
1602
|
+
.map(([, s]) => s.clientId));
|
|
1603
|
+
let clientId = baseId;
|
|
1604
|
+
for (let n = 2; takenIds.has(clientId); n++)
|
|
1605
|
+
clientId = `${baseId}-${n}`;
|
|
1520
1606
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
|
|
1521
1607
|
transport.onclose = () => {
|
|
1522
1608
|
if (transport.sessionId) {
|
|
@@ -1535,6 +1621,9 @@ app.all("/mcp", async (req, res) => {
|
|
|
1535
1621
|
const now = Date.now();
|
|
1536
1622
|
sessions[transport.sessionId] = {
|
|
1537
1623
|
clientId, tag: sessionTag, host, connectedAt: now, lastSeen: now,
|
|
1624
|
+
clientName: earlyClientName,
|
|
1625
|
+
clientVersion: initBody?.params?.clientInfo?.version,
|
|
1626
|
+
workspaceName,
|
|
1538
1627
|
};
|
|
1539
1628
|
}
|
|
1540
1629
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
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
|
@@ -613,39 +613,66 @@ function renderLogEntry(raw) {
|
|
|
613
613
|
if (atBottom) panel.scrollTop = panel.scrollHeight;
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
function sessionStatus(lastSeen) {
|
|
617
|
+
const age = Date.now() - lastSeen;
|
|
618
|
+
if (age < 35_000) return "live";
|
|
619
|
+
if (age < 95_000) return "idle";
|
|
620
|
+
return "stale";
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function dismissSession(clientId) {
|
|
624
|
+
await fetch(`/api/sessions/${encodeURIComponent(clientId)}`, { method: "DELETE" });
|
|
625
|
+
refreshSessions();
|
|
626
|
+
}
|
|
627
|
+
|
|
616
628
|
async function refreshSessions() {
|
|
617
629
|
try {
|
|
618
630
|
const res = await fetch("/api/sessions");
|
|
619
631
|
if (!res.ok) return;
|
|
620
632
|
const { sessions } = await res.json();
|
|
621
633
|
const bar = $("session-pills");
|
|
622
|
-
// Build a set of desired pills (keyed by clientId). Keep the "All" pill.
|
|
623
634
|
const existing = new Map();
|
|
624
|
-
bar.querySelectorAll(".pill").forEach(p =>
|
|
635
|
+
bar.querySelectorAll(".pill[data-client]").forEach(p => {
|
|
636
|
+
if (p.dataset.client !== "") existing.set(p.dataset.client, p);
|
|
637
|
+
});
|
|
625
638
|
const desired = new Set([""]);
|
|
626
639
|
for (const s of sessions) desired.add(s.clientId);
|
|
627
|
-
|
|
640
|
+
|
|
641
|
+
// Remove pills for sessions the server no longer knows about.
|
|
628
642
|
for (const [id, el] of existing) {
|
|
629
643
|
if (!desired.has(id)) el.remove();
|
|
630
644
|
}
|
|
631
|
-
|
|
645
|
+
|
|
646
|
+
// Add or update pills.
|
|
632
647
|
for (const s of sessions) {
|
|
648
|
+
const status = sessionStatus(s.lastSeen);
|
|
649
|
+
const label = s.tag ? `@${s.tag}` : s.clientId;
|
|
650
|
+
const title = [s.workspaceName ?? s.clientName, s.host, `last seen ${Math.round((Date.now() - s.lastSeen) / 1000)}s ago`].filter(Boolean).join(" · ");
|
|
651
|
+
|
|
633
652
|
if (!existing.has(s.clientId)) {
|
|
634
653
|
const btn = document.createElement("button");
|
|
635
654
|
btn.className = "pill";
|
|
636
655
|
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
656
|
btn.onclick = () => selectLogFilter(s.clientId);
|
|
640
657
|
bar.appendChild(btn);
|
|
658
|
+
existing.set(s.clientId, btn);
|
|
641
659
|
}
|
|
660
|
+
|
|
661
|
+
const btn = existing.get(s.clientId);
|
|
662
|
+
btn.title = title;
|
|
663
|
+
btn.innerHTML =
|
|
664
|
+
`<span class="pill-dot pill-dot-${status}"></span>` +
|
|
665
|
+
`<span class="pill-label">${label}</span>` +
|
|
666
|
+
(status === "stale"
|
|
667
|
+
? `<span class="pill-dismiss" title="Remove" onclick="event.stopPropagation();dismissSession('${s.clientId.replace(/'/g,"\\'")}')">×</span>`
|
|
668
|
+
: "");
|
|
642
669
|
}
|
|
670
|
+
|
|
643
671
|
// If the currently-selected client disconnected, fall back to "All".
|
|
644
672
|
if (logFilterClient && !desired.has(logFilterClient)) {
|
|
645
673
|
selectLogFilter("");
|
|
646
674
|
} else {
|
|
647
|
-
|
|
648
|
-
bar.querySelectorAll(".pill").forEach(p => {
|
|
675
|
+
bar.querySelectorAll(".pill[data-client]").forEach(p => {
|
|
649
676
|
p.classList.toggle("pill-active", (p.dataset.client || "") === logFilterClient);
|
|
650
677
|
});
|
|
651
678
|
}
|
package/ui/public/style.css
CHANGED
|
@@ -231,6 +231,18 @@ main {
|
|
|
231
231
|
border-color: var(--accent);
|
|
232
232
|
color: #fff;
|
|
233
233
|
}
|
|
234
|
+
.pill { display: inline-flex; align-items: center; gap: 5px; }
|
|
235
|
+
.pill-dot {
|
|
236
|
+
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
|
237
|
+
}
|
|
238
|
+
.pill-dot-live { background: #4ade80; }
|
|
239
|
+
.pill-dot-idle { background: #facc15; }
|
|
240
|
+
.pill-dot-stale { background: #f87171; }
|
|
241
|
+
.pill-dismiss {
|
|
242
|
+
margin-left: 1px; font-size: 13px; line-height: 1;
|
|
243
|
+
opacity: .6; cursor: pointer;
|
|
244
|
+
}
|
|
245
|
+
.pill-dismiss:hover { opacity: 1; }
|
|
234
246
|
|
|
235
247
|
/* ── Cards ───────────────────────────────────────────────────────────────── */
|
|
236
248
|
|