omni-notify-mcp 1.3.34 → 1.3.286

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.
Files changed (66) hide show
  1. package/README.md +202 -212
  2. package/assets/bn-logo-180.png +0 -0
  3. package/assets/bn-logo-192.png +0 -0
  4. package/assets/bn-logo-32.png +0 -0
  5. package/assets/bn-logo-512.png +0 -0
  6. package/assets/bn-logo-64.png +0 -0
  7. package/assets/bn-logo.svg +25 -0
  8. package/assets/favicon.ico +0 -0
  9. package/config.example.json +20 -16
  10. package/dist/agentGateway.js +293 -0
  11. package/dist/daemon.js +95 -0
  12. package/dist/gateway/agentStream.js +88 -0
  13. package/dist/gateway/chatPage.js +1243 -0
  14. package/dist/gateway/claudeProvider.js +376 -0
  15. package/dist/gateway/codexArchive.js +69 -0
  16. package/dist/gateway/codexProvider.js +32 -0
  17. package/dist/gateway/conversations.js +709 -0
  18. package/dist/gateway/copilotProvider.js +172 -0
  19. package/dist/gateway/iconData.js +8 -0
  20. package/dist/gateway/iconDoor.js +23 -0
  21. package/dist/gateway/lanProbe.js +86 -0
  22. package/dist/gateway/mcpDoor.js +304 -0
  23. package/dist/gateway/nativeTerminal.js +403 -0
  24. package/dist/gateway/nativeTerminalWorker.js +168 -0
  25. package/dist/gateway/notifyDoor.js +139 -0
  26. package/dist/gateway/oauthDoor.js +345 -0
  27. package/dist/gateway/panel.js +189 -0
  28. package/dist/gateway/panelPage.js +213 -0
  29. package/dist/gateway/passkeyClient.js +47 -0
  30. package/dist/gateway/passkeyDoor.js +945 -0
  31. package/dist/gateway/provider.js +21 -0
  32. package/dist/gateway/providers.js +15 -0
  33. package/dist/gateway/publicAccess.js +374 -0
  34. package/dist/gateway/restDoor.js +302 -0
  35. package/dist/gateway/sessionDriver.js +1188 -0
  36. package/dist/gateway/terminalDoor.js +226 -0
  37. package/dist/gateway/terminalPage.js +290 -0
  38. package/dist/gateway/tunnel.js +133 -0
  39. package/dist/index.js +37 -629
  40. package/dist/messaging/chatInbound.js +405 -0
  41. package/dist/messaging/deliveryVerdict.js +52 -0
  42. package/dist/messaging/emailInbound.js +292 -0
  43. package/dist/messaging/inboundPayloads.js +41 -0
  44. package/dist/{ui/messaging → messaging}/notificationEngine.js +12 -10
  45. package/dist/{ui/messaging → messaging}/telegramError.js +1 -1
  46. package/dist/notify.js +2709 -0
  47. package/dist/shared/agentSession.js +1099 -0
  48. package/dist/shared/webhost/bullseyeWebHost.js +1182 -0
  49. package/dist/shared/webhost/openedWorkspaces.js +62 -0
  50. package/dist/shared/webhost/package.json +1 -0
  51. package/dist/shared/webhost/webviewShim.js +271 -0
  52. package/package.json +35 -26
  53. package/scripts/idle-check.ps1 +1 -1
  54. package/scripts/shared/ownerGuard.cs +665 -0
  55. package/scripts/shared/ownerGuard.ps1 +31 -0
  56. package/scripts/shared/ownerGuard.sh +64 -0
  57. package/assets/screenshots/help-page.png +0 -0
  58. package/assets/screenshots/main-ui.png +0 -0
  59. package/dist/ui/server.js +0 -3785
  60. package/ui/public/app.js +0 -1379
  61. package/ui/public/assets/logo.svg +0 -111
  62. package/ui/public/help.css +0 -189
  63. package/ui/public/help.html +0 -272
  64. package/ui/public/help.js +0 -30
  65. package/ui/public/index.html +0 -538
  66. package/ui/public/style.css +0 -796
package/README.md CHANGED
@@ -5,152 +5,229 @@
5
5
  <h1 align="center">BullseyeNotify · <code>omni-notify-mcp</code></h1>
6
6
 
7
7
  <p align="center">
8
- A local notification bus for AI agents. One HTTP server owns the channels, the
9
- routing policy and the inbound message queue; a thin stdio MCP bridge exposes it
10
- to Claude Code, Cursor, Copilot and any other MCP host; a VS Code extension frames
11
- the config UI in the editor.
8
+ Your coding agent, on your machine, from anywhere. BullseyeNotify runs a full interactive Claude Code, Codex
9
+ or Copilot CLI and mirrors that one real session in your browser — a manager of remote CLI sessions, reachable
10
+ from your phone, ChatGPT, claude.ai, Telegram, Slack or email behind a passkey or OAuth sign-in and your own tunnel.
11
+ It reaches you back too: you tell your AI what you want, and it notifies, asks and waits through this server.
12
+ Desktop, Telegram, Slack, ntfy, Discord, Teams and SMS are configured in one file, and Do Not Disturb
13
+ and idle detection decide what actually gets through.
12
14
  </p>
13
15
 
14
16
  <p align="center">
15
- <a href="https://www.npmjs.com/package/omni-notify-mcp"><img src="https://img.shields.io/npm/v/omni-notify-mcp.svg" alt="npm"></a>
17
+ <a href="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/10-chat.png"><img src="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/10-chat.png" width="620" alt="A conversation in BullseyeNotify's web page: the agent's answer drawn as Markdown with a real table, and the measured times"></a>
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/09b-live-cli.png"><img src="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/09b-live-cli.png" width="300" alt="A real agent CLI running on the machine, mirrored in the browser"></a>
22
+ <a href="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/08-agent-panel.png"><img src="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/08-agent-panel.png" width="200" alt="The panel with Start, Stop and Restart"></a>
23
+ <a href="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/11-chat-phone.png"><img src="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/11-chat-phone.png" width="140" alt="The chat on a phone"></a>
24
+ </p>
25
+
26
+ The extension README lists the 13 `agent_*` tools of the `bn` MCP, the 10 tools of the `notify` MCP, every door in, the sign-in methods and the panel's server controls.
27
+
28
+ <p align="center">
16
29
  <a href="https://marketplace.visualstudio.com/items?itemName=menihillel.bullseye-notify"><img src="https://img.shields.io/visual-studio-marketplace/v/menihillel.bullseye-notify?label=marketplace" alt="VS Code Marketplace"></a>
17
30
  <img src="https://img.shields.io/badge/license-MIT-4ea3ff.svg" alt="MIT license">
18
31
  <img src="https://img.shields.io/badge/node-%3E%3D18-4ea3ff.svg" alt="Node 18+">
19
32
  </p>
20
33
 
21
34
  <p align="center">
22
- <img src="https://raw.githubusercontent.com/menih/BullseyeNotif/main/assets/screenshots/main-ui.png" width="900" alt="Config UI — delivery channels and system policies side by side, with the activity log below">
35
+ <a href="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/01-config-panel.png"><img src="https://raw.githubusercontent.com/menih/ext-listing-assets/main/bullseye-notify/01-config-panel.png" width="900" alt="The config panel — every channel and its credentials, Do Not Disturb and quiet hours, idle and the master mute"></a>
23
36
  </p>
24
37
 
25
38
  ---
26
39
 
27
- ## The two processes
40
+ ## A manager of remote CLI sessions
28
41
 
29
- | Process | Entry | What it owns |
30
- |---|---|---|
31
- | **UI/HTTP server** | `dist/ui/server.js` (bin `omni-notify-ui`) | Every channel implementation, the routing policy (DND / idle / mute), the inbound inbox, the Telegram long-poll listener, the Slack channel poller, the built-in ntfy server, the web config UI, and — when `ENABLE_MCP=1` — the Streamable-HTTP `/mcp` endpoint. Listens on port 3737 on every interface, IPv4 and IPv6, by default. |
32
- | **stdio MCP bridge** | `dist/index.js` (bin `omni-notify-mcp`) | A stateless proxy. Auto-spawns the server if `:3737` isn't answering, opens one persistent `/mcp` session, forwards every tool call to it, subscribes to `/api/inbox/stream` and re-emits each message as a `notifications/claude/channel` event. Holds no config and no queue. |
42
+ Beyond notifications, BullseyeNotify runs a full interactive agent CLI on your machine and mirrors it to the browser. The gateway's `/mcp` door carries the `agent_*` tools, and the same host serves `GET /` — BN's one page, where you open a real `claude`, `codex` or `copilot` CLI (drawn through xterm.js over a node-pty worker in Node), attach, reattach and close it, and read the message conversations the REST, MCP, Telegram and email doors create. The browser shows that one CLI and adds nothing of its own; there is no second UI and no second way to start a session. An MCP caller can attach to a session's live stream — the agent's thinking and output as they arrive — instead of waiting for the finished reply.
43
+
44
+ ## One process, one door
33
45
 
34
- The VS Code extension (`vscode-extension/`) is a third, optional face: it starts the
35
- server if needed, renders `http://localhost:3737/` inside a webview, reports on its status
36
- bar whether the server can actually deliver, and writes the stdio bridge into both MCP
37
- host config files (`~/.claude.json` and the open folder's `.vscode/mcp.json`).
46
+ `omni-notify-mcp` **is** the notification server. In the shipped VS Code extension one
47
+ copy of it runs at login and serves a single door on loopback
48
+ (`http://127.0.0.1:7475/mcp`); Claude Code and VS Code / Copilot Chat are pointed at
49
+ that door, so neither spawns a child per session. The same source is also a plain stdio
50
+ server for anything that can only spawn one:
38
51
 
39
- > **Build note.** `package.json` is deliberately untracked in this repo (see
40
- > `.gitignore`), so the dependency list, the `bin` map, the version and the extension
41
- > manifest are not in git. The published package declares `build` as
42
- > `tsc && tsc -p ui/tsconfig.json` and `test` as `npm run build && node --test tests/*.test.mjs`.
52
+ ```bash
53
+ node dist/index.js # the stdio entry; a stdio host spawns this and holds its pipe
54
+ ```
55
+
56
+ The config is a plain file (below), and the editor-side panel that edits it lives in
57
+ the VS Code extension. A stdio host's copy exits when its host goes away; the resident
58
+ process the extension starts does not — it belongs to no window and outlives them all.
59
+
60
+ > **Build note.** `package.json` is deliberately tracked here (see `.gitignore`), so
61
+ > `bin`, `main` and the build scripts are all in git. The published package declares
62
+ > `build` as `tsc` and `test` as `npm run build && node tests/run-tests.js`.
43
63
 
44
64
  ## Quick start
45
65
 
46
66
  ```bash
47
67
  npm install
48
68
  npm run build
49
- ENABLE_MCP=1 node dist/ui/server.js # or: ./notify-ui.sh (no MCP)
50
69
  ```
51
70
 
52
- Open <http://localhost:3737>, enable the channels you want, hit the per-channel
53
- **Test** button. Config is written to `~/.notify-mcp/config.json` and re-read on
54
- every notification — no restart after a settings change.
71
+ Then register the server with an MCP host. Install the VS Code extension from the
72
+ marketplace and it does the wiring for you — it ships the server inside the `.vsix`,
73
+ starts one resident copy of it at login from a path mirrored outside the versioned
74
+ install folder, and points Claude Code and VS Code / Copilot Chat at the door that copy
75
+ serves:
55
76
 
56
- Register the bridge with an MCP host:
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "notify": { "type": "http", "url": "http://127.0.0.1:7475/mcp" }
81
+ }
82
+ }
83
+ ```
84
+
85
+ A host that can only spawn a stdio server takes the mirrored bundle instead, starting
86
+ its own child per session:
57
87
 
58
88
  ```json
59
89
  {
60
90
  "mcpServers": {
61
- "notify": { "type": "stdio", "command": "npx", "args": ["-y", "omni-notify-mcp"] }
91
+ "notify": { "type": "stdio", "command": "node", "args": ["<globalStorage>/dist/index.mjs"] }
62
92
  }
63
93
  }
64
94
  ```
65
95
 
66
- VS Code / Copilot use `.vscode/mcp.json` with a `servers` key instead of
67
- `mcpServers`; the bundled help page at `http://localhost:3737/help.html` has the exact
68
- snippet for Claude Code, Cursor, VS Code, Claude Desktop, Windsurf, Zed and generic
69
- clients.
96
+ ## MCP wiring
97
+
98
+ The extension wires the two hosts whose config files it may write, re-checked on every
99
+ window start so a machine registered by an older release converges on its own:
100
+
101
+ | Host | Where it registers | Key |
102
+ |---|---|---|
103
+ | **Claude Code** | `~/.claude.json` | `mcpServers.notify` |
104
+ | **VS Code / GitHub Copilot Chat** | the extension's `contributes.mcpServerDefinitionProviders` **and** the workspace's `.vscode/mcp.json` | `servers.notify` |
70
105
 
71
- ## MCP tools
106
+ Both point at the door the copy bundled inside the extension serves. Any other host is
107
+ wired by hand, with either entry from *Quick start* — for **Codex** that is
108
+ `~/.codex/config.toml` under `[mcp_servers.notify]`, `command`/`args` naming the mirrored
109
+ bundle. A `notify` entry that differs from ours is yours and is left untouched; a build
110
+ that bundled no server registers nothing and says so.
72
111
 
73
- Eight tools over HTTP, nine over stdio. The agent never names a channel — routing is
112
+ ## Tools
113
+
114
+ Ten tools over the one notify server. The agent never names a channel — routing is
74
115
  entirely server-side.
75
116
 
76
117
  | Tool | Arguments | Behaviour |
77
118
  |---|---|---|
78
- | `notify` | `message`, `priority` = `low`\|`normal`\|`high` | Fan out per the routing table below. Response states `outcome=` with the `attempted` and `delivered` channels plus any per-channel error, and carries any queued inbox messages for this session. |
79
- | `ask` | `question`, `timeout_seconds` (30–3600, default 300) | Sends the question to Telegram (every configured chat id) and email (with a one-shot `/reply/:token` web page), then **blocks** until a reply lands or the timeout throws. |
80
- | `poll` | — | Drains queued inbox messages for this session's tag; returns `inbox:empty` when there are none. |
81
- | `wait_for_inbox` | `timeout_seconds` (5–55, default 50) | Parks a waiter and returns the moment a matching message arrives, as a tool *result*. Returns already-queued messages immediately without parking. |
82
- | `get_idle_seconds` | — | How long since the last OS keyboard/mouse input, as one of two DIFFERENT answers: `idle: measured N seconds …`, or `idle: could not measure — user activity unknown, so activity gating is skipped` when this machine cannot report user activity at all. Drains the inbox as a side effect. |
83
- | `get_idle_config` | — | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`. Drains the inbox. |
84
- | `get_dnd_status` | — | `{ active, reason }` where reason is `disabled` (master mute or this client disabled), `manual`, `schedule` or `off`. Drains the inbox. |
119
+ | `notify` | `message` (≤5000), `priority` = `low`\|`normal`\|`high` | Fan out per the routing table below. The result states `outcome=` with the `attempted` and `delivered` channels, and carries any queued inbox messages for this session. |
120
+ | `ask` | `question` (≤500), `timeout_seconds` (30–3600, default 300) | Sends the question over Telegram and **blocks** until you answer. The wait is clamped to the caller's own transport limit, so it returns `NO REPLY YET` and leaves the question open rather than reporting it as ignored; the answer arrives in the inbox. |
121
+ | `poll` | — | Drains queued inbox messages for this session; returns `inbox:empty` when there are none. |
122
+ | `wait_for_inbox` | `timeout_seconds` (5–55, default 50) | Blocks and returns the moment you type, as a tool *result* — the delivery path that works in every MCP client. Returns already-queued messages immediately. |
123
+ | `get_idle_seconds` | — | How long since the last keyboard/mouse input, as one of two DIFFERENT answers: `idle: measured N seconds …`, or `idle: could not measure — user activity unknown, so activity gating is skipped`. |
124
+ | `get_idle_config` | — | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`. |
125
+ | `get_delivery_status` | — | `{ process, healthy, channels, carriage, reason }` from real transport readings, not from config or from the process being alive. `healthy` is true only when every enabled channel has been MEASURED able to carry. |
126
+ | `get_dnd_status` | — | `{ active, reason }` where reason is `disabled`, `manual`, `schedule` or `off`. |
85
127
  | `update_instructions` | `instructions` (≤4000), `target` = `global`\|`project` | Rewrites a marker-delimited block in `~/.claude/CLAUDE.md` (global) or `./.claude/CLAUDE.md` (project). |
86
- | `reply` *(stdio bridge only)* | `message` (≤2000), `priority` | The Claude Code Channels return path. Prefixes the bridge's session tag and routes through `notify`. |
128
+ | `reply` | `message` (≤2000) | The return path for your most recent channel message, routed back through `notify` so it lands on whichever channel you are reading. |
129
+
130
+ **Message size and chunking.** `notify` accepts up to 5000 characters and splits
131
+ anything over 500 into numbered `(1/N) …` chunks (word-aware where a break lands past
132
+ 60 % of the budget), delivered in order. The agent sends the whole message in one call
133
+ and never truncates.
87
134
 
88
- **Message size and chunking.** The HTTP server caps `notify.message` at 500
89
- characters and rejects anything longer. The stdio bridge accepts up to 5000, splits it
90
- in `src/chunk.ts` into `(1/N) …` chunks of ≤500 (word-aware where a break lands past
91
- 60 % of the budget), and delivers them in order — so chunking exists **only on the
92
- bridge path**. A bridge caller gets back either `Delivered as N chunks (M chars).` or a
93
- count of how many chunks reached no channel and what the server said.
135
+ ## Timing an answer
136
+
137
+ Every answer in an agent conversation, and every turn that failed, carries a `timing`
138
+ object, and every figure in it was measured on **BN's own clock** — the clock of the BN
139
+ process that stored the event. Both doors return the same names.
140
+
141
+ | Field | What it is |
142
+ |---|---|
143
+ | `bnMs` | The end-to-end elapsed time of a reply: the milliseconds from the request arriving at BN's door to the answer being stored — the door's own `receivedMs` plus `totalMs`. It is never the agent's time; that is `agentMs`. |
144
+ | `queuedMs` | The part of `bnMs` the message waited for a free slot or the turn ahead — the stored queue row's own measured span, from the message being stored to the turn starting. Present exactly when the turn queued and then started; never a subtraction made by a reader. |
145
+ | `queued` | Whether the turn stood in line at all. `false` is the store's own statement that no queue line was written for that turn — it never stood in line; absent means no queue figure was ever stored for it, which is neither. |
146
+ | `totalMs` | BN's own total: the part from the message being stored in the conversation's log to the answer being stored. It sits inside `bnMs` and is never relabelled as the agent's time. |
147
+ | `agentMs` | The agent CLI's own share — the part of the turn the agent itself worked, without BN's time around it. Absent on a failure BN stored outside a turn of its own: no agent share was measured there, so none is printed. |
148
+ | `rows` | The turn layer by layer, each with its own `ms`; a row marked `part` sits inside the row above it and is never added to the total. |
149
+ | `receivedMs` | On the message: the door's own wait, the request arriving → the message stored. A REST send answers it, and it is the first half of every answer's `bnMs`. |
150
+ | `waitedMs` | On the call that hands a turn's end to its caller: how long that `agent_wait` or `agent_prompt` call held, measured on BN's own clock and written on its `returned` line and returned by `agent_prompt`. |
151
+ | `servedAt` | On a REST events read: when BN served it, so a page tells BN's time from its own. |
152
+
153
+ A reply's time is read as three separate figures and never as one number: the end-to-end
154
+ `bnMs`, the `queuedMs` that was waiting, and the `agentMs` that was the agent working.
155
+ The arithmetic holds on every turn: `bnMs = receivedMs + totalMs`,
156
+ `bnMs >= totalMs >= agentMs >= 0` wherever `agentMs` is present, and
157
+ `queuedMs` is the span of the queue row inside `totalMs` — so on a turn that queued and
158
+ started, the queued figure and the row a reader sees are the same number. `agent_prompt` returns
159
+ the ending event's `timing` beside the answer text; `agent_wait` returns the events
160
+ themselves, each with its own. `GET /v1/conversations/{id}` carries no second copy of a
161
+ turn's time — the events read is the one way to learn it.
162
+
163
+ **Where the figures stop.** These are the three edges of the measurement, and each one
164
+ prints nothing rather than a guess. A message stored before this change carries no
165
+ `receivedMs`, so its answer has no `bnMs` — there is no measured door wait to add. A
166
+ failure BN stores for a message it holds no request for is written without a `timing` at
167
+ all. A turn that crossed a BN restart reads the wait before it off the stored times the
168
+ earlier BN process wrote, so that one row is that process's wall clock, named as such in
169
+ its own words.
170
+
171
+ **What BN cannot see.** Voice transcription, the ChatGPT dispatch, the plugin's own
172
+ transit, the client's network and the page's render all happen outside BN's process, so
173
+ the rows name them `UNMEASURED` and give them no figure. None of them is ever worked out
174
+ by subtracting BN's own numbers from a total: a figure BN did not measure is never
175
+ printed as though it had.
94
176
 
95
177
  ## Delivery routing
96
178
 
97
- `sendWithRouting` in `ui/messaging/notificationEngine.ts` is the single chokepoint.
98
- Three suppressions run before it, in order: master mute (`muteAll`) drops everything
179
+ Suppression runs first, in order: **master mute** (`muteAll`) drops everything
99
180
  including `high`; a client listed in `disabledClients` is dropped by client id; then
100
- DND drops everything below `high`.
101
-
102
- The **desktop-only** mode kicks in when the web UI reported itself visible in the last
103
- 30 s, or when idle gating is on, no Telegram conversation has been active in the last
104
- 5 minutes, and OS idle time is under the threshold.
105
-
106
- > With `idle.alwaysDesktopWhenActive: false` the desktop is held back too while the user
107
- > is active, so only Slack is attempted. When Slack is off nothing is sent, and the reply
108
- > says `outcome=blocked`, as it does under DND, never `outcome=failed`.
109
-
110
- | Channel | `low` | `normal` | `high` | Under desktop-only |
111
- |---|---|---|---|---|
112
- | desktop | — | ✓ | ✓ | ✓ |
113
- | telegram | — | ✓ | ✓ | — |
114
- | email | ✓ | ✓ | ✓ | — |
115
- | ntfy | ✓ | ✓ | ✓ | — |
116
- | discord | ✓ | ✓ | ✓ | — |
117
- | teams | ✓ | ✓ | ✓ | — |
118
- | slack | ✓ | ✓ | ✓ | **✓ — exempt from idle gating** |
119
- | sms | — | — | ✓ | — |
120
-
121
- `priority: 'high'` bypasses DND and idle gating both. Per-channel failures are
122
- collected, never thrown: the tool response lists what was delivered and what errored.
181
+ **Do Not Disturb** holds everything, `high` included — nothing reaches you until it
182
+ lifts. A deliberate hold is a choice and never wears the same face as a defect.
183
+
184
+ Then **idle gating**: while you are at the keyboard, a non-`high` message is held back
185
+ from the remote channels and, with `idle.alwaysDesktopWhenActive: true`, still shows as
186
+ a silent desktop toast. Slack is deliberately exempt so a channel log stays complete;
187
+ when `alwaysDesktopWhenActive: false` the desktop is held back too, so only that Slack
188
+ log is used. `priority: 'high'` bypasses idle gating only — DND holds it too.
189
+
190
+ | Channel | `low` | `normal` | `high` |
191
+ |---|---|---|---|
192
+ | desktop | — | ✓ | ✓ |
193
+ | telegram | — | ✓ | ✓ |
194
+ | ntfy | ✓ | ✓ | ✓ |
195
+ | discord | ✓ | ✓ | ✓ |
196
+ | slack | ✓ | ✓ | ✓ |
197
+ | teams | ✓ | ✓ | ✓ |
198
+ | sms | — | — | ✓ |
199
+
200
+ Per-channel failures are collected, never thrown: the tool result lists what was
201
+ delivered and what errored.
123
202
 
124
203
  ## Channels
125
204
 
126
205
  Every channel is off until enabled, and a channel whose credentials are incomplete is
127
- skipped by the `enable*` predicates in `ui/server.ts` even when `enabled: true`.
206
+ skipped even when `enabled: true`. There is **no email channel** and **no built-in
207
+ ntfy server** — ntfy is an external server you name.
128
208
 
129
209
  | Channel | Config key and fields | Transport |
130
210
  |---|---|---|
131
- | **Desktop** | `desktop` — `enabled`, `sound`, `tts`, `ttsVoice` | `node-notifier` toast titled *Claude Notify*. On Windows the toast sound is unreliable, so a PowerShell `SystemSounds::Asterisk` is fired alongside it. With `tts: true` the body is synthesised through `msedge-tts` (default voice `en-US-AndrewMultilingualNeural`) and played via MediaPlayer / `afplay` / `aplay`. |
132
- | **Telegram** | `telegram` — `enabled`, `token`, `chatIds[]` | `sendMessage` per chat id. When the last inbound message came from that same chat, the notification is sent as a threaded reply. Also the inbound listener (see below). |
133
- | **Email** | `email` — `enabled`, `to`, plus either Gmail OAuth (`clientId`, `clientSecret`, `refreshToken`, `accessToken`, `connectedEmail`) or SMTP (`host`, `port` 587, `secure` false, `user`, `pass`) | `nodemailer`; subject *Claude Notify*. OAuth wins when a refresh token is present. `ask` sends HTML with a **Reply to Claude** button pointing at `http://<lan-ip>:3737/reply/<token>`. |
134
- | **SMS** | `sms` — `enabled`, `accessKeyId`, `secretAccessKey`, `region`, `originationNumber`, `to[]` | AWS End User Messaging (`@aws-sdk/client-pinpoint-sms-voice-v2`, `SendTextMessage`), one call per destination, numbers normalised to E.164. `high` priority only. |
135
- | **ntfy** | `ntfy` — `enabled`, `topic`, `serverUrl` (display only), `token` | **This server *is* the ntfy server.** It implements the publish/subscribe wire protocol at `/:topic/sse`, `/:topic/json` and `PUT`/`POST /:topic`, and a notification fans out to whoever is subscribed to that topic *on this server*. Point the ntfy mobile app at this host. Sending with zero subscribers is an error, not a silent drop. |
136
- | **Discord** | `discord` — `enabled`, `webhookUrl`, `username`, `clientId`, `clientSecret`, `channelName` | Webhook embed, colour-coded by priority. `GET /auth/discord/start` runs a one-click OAuth flow (`webhook.incoming` scope) that returns a ready-made channel webhook. |
137
- | **Slack** | `slack` — `enabled`, `webhookUrl`, `botToken`, `channels[]`, `clientId`, `clientSecret`, `team` | With a bot token **and** channels, one `chat.postMessage` per channel; otherwise the single incoming webhook. `GET /auth/slack/start` runs OAuth for `channels:read,groups:read,channels:history,groups:history,chat:write,chat:write.public`, so the minted token also serves the Slack bus poller. When `slack.botToken` is blank the token from `notify-secrets.json` / `SLACK_BOT_TOKEN` is reused. |
211
+ | **Desktop** | `desktop` — `enabled`, `tts`, `ttsVoice` | `node-notifier` toast titled *Claude Notify*, always silent. With `tts: true` the body is read aloud through `msedge-tts` — no API key. |
212
+ | **Telegram** | `telegram` — `enabled`, `token`, `chatIds[]` | `sendMessage` per chat id; also the inbound listener. The bot answers in-thread, naming whether your message was routed, broadcast or queued. |
213
+ | **SMS** | `sms` — `enabled`, `accessKeyId`, `secretAccessKey`, `region`, `originationNumber`, `to[]` | AWS End User Messaging (`SendTextMessage`), one call per destination, numbers normalised to E.164. `high` priority only. |
214
+ | **ntfy** | `ntfy` — `enabled`, `topic`, `serverUrl`, `token` | HTTP POST to an external ntfy server you name (`ntfy.serverUrl`, e.g. a self-hosted one or `https://ntfy.sh`), so the ntfy mobile app gets the push. |
215
+ | **Discord** | `discord` — `enabled`, `webhookUrl`, `username` | Webhook embed, colour-coded by priority. |
216
+ | **Slack** | `slack` — `enabled`, `webhookUrl`, `botToken`, `channels[]` | With a bot token **and** channels, one `chat.postMessage` per channel; otherwise the single incoming webhook. The same token serves the Slack bus poller. |
138
217
  | **Teams** | `teams` — `enabled`, `webhookUrl` | Adaptive Card v1.2 posted to a Teams *Workflows* webhook. |
139
218
 
140
219
  ## Configuration
141
220
 
142
- `~/.notify-mcp/config.json`, overridable with `NOTIFY_MCP_CONFIG_DIR`. The UI writes
143
- it; secrets are replaced with `••••••••` on read and a masked value posted back is
144
- restored from the on-disk copy rather than overwriting it. Legacy single-destination
145
- fields are migrated on load: `telegram.chatId` → `chatIds[]`, `sms.to` string →
146
- array, and the dead Twilio fields (`accountSid`, `authToken`, `from`) are deleted.
221
+ `~/.notify-mcp/config.json`, overridable with `NOTIFY_MCP_CONFIG_DIR`. It is a plain
222
+ file, written by hand or by the extension's config panel, and re-read on every
223
+ notification — no restart after a settings change.
147
224
 
148
225
  ```json
149
226
  {
150
227
  "muteAll": false,
151
228
  "disabledClients": [],
152
229
  "clientAliases": {},
153
- "desktop": { "enabled": true, "sound": true, "tts": false },
230
+ "desktop": { "enabled": true },
154
231
  "telegram": { "enabled": true, "token": "BOT_TOKEN", "chatIds": ["123456789"] },
155
232
  "sms": {
156
233
  "enabled": false,
@@ -158,11 +235,6 @@ array, and the dead Twilio fields (`accountSid`, `authToken`, `from`) are delete
158
235
  "region": "us-east-1", "originationNumber": "+15550000000",
159
236
  "to": ["+15550000001"]
160
237
  },
161
- "email": {
162
- "enabled": true, "to": "you@gmail.com",
163
- "host": "smtp.gmail.com", "port": 587, "secure": false,
164
- "user": "you@gmail.com", "pass": "GMAIL_APP_PASSWORD"
165
- },
166
238
  "ntfy": { "enabled": false, "topic": "", "serverUrl": "" },
167
239
  "discord": { "enabled": false, "webhookUrl": "", "username": "Claude Notify" },
168
240
  "slack": { "enabled": false, "webhookUrl": "", "botToken": "", "channels": [] },
@@ -175,59 +247,23 @@ array, and the dead Twilio fields (`accountSid`, `authToken`, `from`) are delete
175
247
  }
176
248
  ```
177
249
 
178
- `notify-secrets.json` at the repo root is a second, optional source: values under keys
250
+ A partial file is a legitimate config — a section it omits is created on load, and
251
+ legacy single-destination fields are migrated (`telegram.chatId` → `chatIds[]`).
252
+
253
+ **Secrets.** `notify-secrets.json` is a second, optional source: values under keys
179
254
  ending in `_b64` are base64-decoded at load, and on startup any credential it holds is
180
255
  copied into **empty** fields of `config.json` (never over a value you already set).
181
- See `config.example.json` for a hand-written starting point.
182
-
183
- **This only works if the running server can actually find that file.** The lookup path is
184
- `NOTIFY_MCP_SECRETS_PATH`, defaulting to `<CONFIG_DIR>/notify-secrets.json` — and `CONFIG_DIR`
185
- defaults to `~/.notify-mcp`, a **home-relative** path, not the repo root. A server started via
186
- `npx` or the VS Code extension runs from an npm cache directory with no relationship to any git
187
- checkout, so simply having `notify-secrets.json` committed at the repo root does nothing on its
188
- own (story #438: this sat silently broken for months — every channel stayed disabled and every
189
- `notify` reached nobody, because nothing ever pointed the server at the repo file). Set
190
- `NOTIFY_MCP_SECRETS_PATH` to the repo's `notify-secrets.json` as a **persistent environment
191
- variable** on each machine that has a checkout (`setx NOTIFY_MCP_SECRETS_PATH "C:\path\to\BullseyeNotif\notify-secrets.json"`
192
- on Windows, or export it from your shell profile elsewhere) — set once, it survives reboots and
193
- every future server restart on that machine picks it up. Check `GET /api/config` after a restart:
194
- a channel listed in `notify-secrets.json` with `enabled: true` and its required fields populated
195
- should show `enabled: true` there too — if it still shows `false`, the file was not found.
196
-
197
- ## Inbound — the inbox
198
-
199
- Messages arriving *from* the user (Telegram reply, Slack channel post,
200
- `POST /api/agent/inbox/inject`, Slack Events API) land in one queue and are delivered
201
- through whichever path is available:
202
-
203
- 1. **Parked long-poll waiters** get first refusal — `wait_for_inbox` or
204
- `GET /api/agent/inbox/wait`, delivered as a tool/HTTP result. Message is handed to
205
- the waiters and not queued.
206
- 2. **SSE broadcast** to every `/api/inbox/stream` subscriber whose tag matches. The
207
- stdio bridge is such a subscriber and re-emits each message as a
208
- `notifications/claude/channel` event, which Claude Code (CLI, launched with
209
- `--channels`) surfaces as a synthetic turn.
210
- 3. **File drop** at `~/.notify-mcp/inbox/<iso-ts>[.<tag>].md`, reaped after 24 h. The
211
- Slack-origin drop embeds the exact `curl` needed to answer in-channel.
212
- `.claude/notify-inbox-drain.sh` is the consumer shipped here: it is wired into
213
- `SessionStart`, `UserPromptSubmit` and `Stop`, matches this session's tag, deletes
214
- each file as it reads it, and returns the text as `additionalContext` (or as a
215
- `decision: "block"` reason on `Stop`, which is what lets a message reach an agent
216
- mid-loop). It deliberately no-ops on `PreToolUse`/`PostToolUse`, where Claude Code
217
- discards hook output — draining there would delete a message without delivering it.
218
-
219
- **Tags.** A session declares one via `/mcp?tag=<name>` or `/api/inbox/stream?tag=<name>`.
220
- A user message prefixed `@<tag> ` is routed to that tag only; everything else
221
- broadcasts. The bridge derives its own tag as
222
- `<hostname>-<project>` where `<project>` is `NOTIFY_MCP_TAG`, else the nearest
223
- meaningful folder walking up from `CLAUDE_PROJECT_DIR` (generic names like `claude`,
224
- `code`, `src`, `dist`, `system32` are skipped). When neither variable is set — the VS
225
- Code extension case — a 6-character hash of `CLAUDE_CODE_SESSION_ID` is appended so two
226
- windows sharing a working directory stay distinct. Subagents inherit their parent's
227
- session id, so `/api/clients` folds them into the parent's panel.
228
-
229
- Telegram acknowledges every inbound message in-thread, naming what it did:
230
- routed to `@tag`, broadcast to *n* listeners, or queued because nobody is connected.
256
+ The lookup path is `NOTIFY_MCP_SECRETS_PATH`, defaulting to
257
+ `<CONFIG_DIR>/notify-secrets.json` — a **home-relative** path, not the repo root. See
258
+ `config.example.json` for a hand-written starting point.
259
+
260
+ ## Tags — many agents, one server
261
+
262
+ Every session declares a tag — host plus project, or an explicit `NOTIFY_MCP_TAG`.
263
+ Untagged messages broadcast to all connected clients; `@<tag> …` goes to exactly one.
264
+ In the config file, `clientAliases` gives a tag a readable name and `disabledClients`
265
+ silences one agent. Subagents inherit their parent's session id, so they fold into the
266
+ parent's inbox.
231
267
 
232
268
  ## Slack bus
233
269
 
@@ -235,85 +271,39 @@ A single shared Slack channel works as a cross-machine bus: the server polls
235
271
  `conversations.history` every 2 s, ingests genuine human messages only, answers
236
272
  `clients` / `help` in-channel itself, and routes `@<name> …` / `#<n> …` to the matching
237
273
  connected client. Full architecture, addressing rules and operating notes:
238
- [`docs/SLACK-BUS.md`](docs/SLACK-BUS.md).
239
-
240
- ## Web UI
274
+ [`../docs/BullseyeNotif/SLACK-BUS.md`](../docs/BullseyeNotif/SLACK-BUS.md).
241
275
 
242
- One page at `:3737`, dark, no build step. A master-mute bar on top; a **Delivery
243
- channels** column (desktop, email, telegram, sms, ntfy, discord, slack, teams — each a
244
- collapsible card with a status badge, an enable toggle, a **Test** button and inline
245
- setup instructions); a **System policies** column (Do Not Disturb manual + scheduled
246
- quiet hours with per-day checkboxes; idle gating threshold and the
247
- always-play-desktop-sound toggle). Below them a collapsible panel with two tabs: the
248
- live activity log (SSE, last 500 lines replayed on connect, direction-marked `→ ← ·`)
249
- and **Clients**, which lists every connected session with its panel number and lets you
250
- rename, disable or force-reconnect one. `help.html` is the copy-paste setup page.
276
+ ## The status bell (VS Code extension)
251
277
 
252
- ## HTTP endpoints
278
+ A status-bar bell that never shows green over a channel that carries nothing, refreshed
279
+ every 10 s. Every state has its own icon and colour, so a defect can never wear the
280
+ costume of a choice:
253
281
 
254
- | Path | Purpose |
255
- |---|---|
256
- | `ALL /mcp[?tag=&hsid=]` | Streamable-HTTP MCP transport. `404 mcp_disabled` unless `ENABLE_MCP=1`. An unknown session id on a fresh `initialize` is adopted (survives a server restart); on any other request it returns 404 so the client reinitialises. |
257
- | `GET /api/inbox/stream[?tag=]` | SSE push of inbound user messages, 20 s keepalive comments. |
258
- | `GET /api/logs` | SSE activity log. |
259
- | `GET/POST /api/config` | Read (masked) / merge-write the config. |
260
- | `GET/POST /api/mute` | Master kill switch. |
261
- | `POST /api/window/register` | Extension → server: `{sessionId, workspaceName, workspacePath}`, so a window's real workspace name can be shown for its bridge session. |
262
- | `GET/POST /api/window/:sessionId/mute` | Mute every client belonging to one editor window. |
263
- | `GET /api/sessions`, `DELETE /api/sessions/:clientId` | Raw MCP session list / drop one. |
264
- | `GET /api/clients` | Connected clients with panel numbering, kinds (`mcp`/`sse`/`waiter`), workspace name and disabled flag. |
265
- | `POST /api/clients/:tag/rename`, `/api/clients/:id/disable` | Persisted display alias / per-client kill switch. |
266
- | `POST /api/clients/:tag/reconnect`, `/api/clients/:tag/panel/:sessionId/reconnect` | Drop every connection for a tag, or just one panel. |
267
- | `POST /api/test/{sound,tts,desktop,telegram,sms,email,ntfy,discord,slack,teams}` | One-shot test send per channel. |
268
- | `GET /api/voices` | English neural voices from msedge-tts, cached 24 h. |
269
- | `GET /api/telegram/chats`, `/api/slack/channels`, `/api/sms/numbers` | Pickers backing the UI. |
270
- | `GET /api/slack/status`, `/api/discord/status`, `/api/gcloud/status` | Connection state for the UI badges. |
271
- | `GET /auth/{google,slack,discord}/start`, `/callback`; `DELETE /auth/{google,slack,discord}` | OAuth connect / disconnect. |
272
- | `GET /api/google/open-apppasswords`, `POST /api/google/apppassword`, `GET /api/gcloud/login` | Gmail App Password helper flow and the gcloud sign-in it can fall back on. |
273
- | `POST /api/agent/notify`, `GET /api/agent/inbox/poll`, `GET /api/agent/inbox/wait`, `POST /api/agent/inbox/inject`, `POST /api/agent/slack/reply` | Plain-HTTP agent API — same behaviour as the MCP tools, no transport required. Gated by `x-notify-key` when `NOTIFY_AGENT_KEY` is set. |
274
- | `POST /api/session/state` | A session reports itself busy/idle so the bus can answer "Claude is busy, ~Ns". |
275
- | `POST /api/slack/events` | Slack Events API receiver; HMAC-verified when `SLACK_SIGNING_SECRET` is set. |
276
- | `POST /api/ui/visibility` | The web UI heartbeats its own visibility; while visible, non-`high` notifications stay desktop-only. |
277
- | `GET /reply/:token`, `POST /reply/:token` | One-shot web reply page for `ask` over email. |
278
- | `GET /v1/health`, `/v1/info` | Delivery report read by the VS Code extension's status bar: `{healthy, process, mcp, channels, reason}`. `healthy` is true only when the MCP endpoint is enabled **and** at least one channel is enabled and able to carry, so a listening server that could send nothing is reported unhealthy rather than green. `/v1/info` stays a version stub. |
279
- | `/:topic/sse`, `/:topic/json`, `PUT`/`POST /:topic`, `/:topic/subscribers` | Built-in ntfy protocol server (also mounted under `/ntfy/…`). |
280
-
281
- `POST /__test__/inject-inbox` and `GET /__test__/slack-clients` exist only when
282
- `NOTIFY_MCP_TEST_ENDPOINTS=1`.
283
-
284
- ## Environment
285
-
286
- | Variable | Read by | Effect |
287
- |---|---|---|
288
- | `PORT` / `NOTIFY_MCP_PORT` | server / bridge | Listen port, default 3737. |
289
- | `ENABLE_MCP` | server | `1` enables `/mcp`. The bridge always sets it when it spawns the server; a manual relaunch that forgets it makes every MCP tool 404. |
290
- | `NOTIFY_MCP_CONFIG_DIR` | server | Config + inbox-drop root, default `~/.notify-mcp`. |
291
- | `NOTIFY_MCP_TAG` | bridge, `notify-watch.sh`, drain hook | Explicit session tag. |
292
- | `NOTIFY_AGENT_KEY` | server | When set, `/api/agent/*` requires header `x-notify-key`. |
293
- | `SLACK_BOT_TOKEN`, `SLACK_CHANNEL_ID`, `SLACK_POLL_INTERVAL`, `SLACK_SIGNING_SECRET` | server | Slack bus credentials, poll interval (seconds, default 2) and Events-API signature verification. |
294
- | `NOTIFY_MCP_NO_OPEN`, `BROWSER=none` | server | Suppress the first-run browser pop. |
295
- | `NOTIFY_MCP_TEST_ENDPOINTS` | server | `1` enables the test-only routes, isolates the inbox drop dir, and skips the live Telegram/Slack listeners. |
296
- | `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_SESSION_ID` | bridge, extension | Supplied by the host; drive tag derivation and subagent folding. |
297
-
298
- ## Scripts
299
-
300
- | Script | What it does |
282
+ | Face | Meaning |
301
283
  |---|---|
302
- | `notify-ui.sh` | Build if `dist/ui/server.js` is missing, then run the server in the foreground (**without** `ENABLE_MCP`). |
303
- | `restart.sh` | `npm run build:ui`, stop the one pid holding `:3737`, relaunch detached with `ENABLE_MCP=1` to `/tmp/notify-mcp.log`, then read `/v1/health` and exit non-zero unless the server reports it can deliver. |
304
- | `bus-up.sh` | Singleton watchdog: keeps `:3737` listening, redeploys when `dist/ui/server.js` changes on disk, and keeps one `notify-watch.sh` alive. Logs to `.run/`. |
305
- | `notify-watch.sh` | Detached responder. Long-polls `/api/agent/inbox/wait` for `<host>-<folder>-bot`, and passes every message **verbatim** to a headless `claude -p` that answers via `POST /api/agent/slack/reply`. |
306
- | `scripts/agent-notify.sh`, `agent-poll-inbox.sh`, `agent-wait-inbox.sh`, `agent-listen-loop.sh` | curl wrappers over the plain-HTTP agent API (`NOTIFY_BASE_URL`, `NOTIFY_AGENT_KEY`, `NOTIFY_TAG`, `NOTIFY_ON_MESSAGE_CMD`). |
307
- | `scripts/bus-startup-task.sh` | Registers `bus-up.sh` with Windows Task Scheduler. |
308
- | `scripts/run-tunnel-loop.sh` | Keeps a localtunnel pointed at the port. |
309
- | `release.sh`, `setup-secrets.sh` | npm + Marketplace publish, and the guided credential setup it calls on first run. |
284
+ | $(bell) Notify | running and able to deliver |
285
+ | $(bell-slash) Muted | master mute is on — everything, including `high`, is dropped |
286
+ | $(bell-slash) Win Muted | this window is muted |
287
+ | $(error) Notify DOWN | the server is not answering at all |
288
+ | $(error) Notify UNCHECKED | the delivery check never got an answer — nothing is claimed either way |
289
+ | $(error) Notify NO REPORT | the server answered but said nothing about delivery |
290
+ | $(error) Notify MCP OFF | the MCP endpoint is disabled — nothing can be sent through it |
291
+ | $(error) Notify NO CHANNEL | no channel is enabled, so every send reaches nobody |
292
+ | $(bell) Notify N on | channels are enabled and NOTHING has measured whether any can carry |
293
+ | $(error) Notify MUTE UNREAD | the mute state came back unreadable — not the same as "not muted" |
294
+
295
+ ## The reload control (VS Code extension)
296
+
297
+ **🔃 Reload** joins the shared Restorable Reload used by every Bullseye extension. Notify
298
+ brings back nothing across a reload — it keeps no per-window state: the notify process is
299
+ the one started at login, outside every window, and every setting lives in the config
300
+ file, which a reload does not touch. So it registers the lifecycle and no participants.
310
301
 
311
302
  ## Tests
312
303
 
313
- `tests/smoke.test.mjs` — end-to-end only. It spawns a real server on a random free
314
- port with an isolated config dir, drives it over real HTTP, and runs the stdio bridge
315
- as a real subprocess; a local capture server stands in for the Slack webhook. Run with
316
- `npm test` (which builds first) or `node --test tests/smoke.test.mjs` against an
304
+ The suite is end-to-end: it spawns the real built server on a random config dir and
305
+ drives it over a real MCP session, with local stand-in servers for the Telegram and
306
+ Slack APIs. Run with `npm test` (which builds first) or `npm run test:files` against an
317
307
  existing `dist/`.
318
308
 
319
309
  ## License
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,25 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="BullseyeNotify">
3
+ <title>BullseyeNotify</title>
4
+ <defs>
5
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
6
+ <stop offset="0%" stop-color="#1a3550"/>
7
+ <stop offset="55%" stop-color="#1f5a96"/>
8
+ <stop offset="100%" stop-color="#4ea3ff"/>
9
+ </linearGradient>
10
+ <radialGradient id="glow" cx="0.35" cy="0.3" r="0.7">
11
+ <stop offset="0%" stop-color="#ffffff" stop-opacity="0.22"/>
12
+ <stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
13
+ </radialGradient>
14
+ </defs>
15
+ <rect width="512" height="512" rx="112" fill="url(#bg)"/>
16
+ <rect width="512" height="512" rx="112" fill="url(#glow)"/>
17
+ <circle cx="256" cy="256" r="196" fill="#ffffff"/>
18
+ <circle cx="256" cy="256" r="196" fill="none" stroke="#1a3550" stroke-width="8"/>
19
+ <circle cx="256" cy="256" r="150" fill="#1f5a96"/>
20
+ <circle cx="256" cy="256" r="104" fill="#ffffff"/>
21
+ <circle cx="256" cy="256" r="58" fill="#34d399"/>
22
+ <circle cx="256" cy="256" r="22" fill="#0f2a40"/>
23
+ <path d="M256 256 L420 92" stroke="#ffffff" stroke-width="18" stroke-linecap="round"/>
24
+ <path d="M420 92 l-6 -50 m6 50 l50 -6" stroke="#ffffff" stroke-width="18" stroke-linecap="round" fill="none"/>
25
+ </svg>
Binary file