omni-notify-mcp 1.3.30 → 1.3.285

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 -210
  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 +1240 -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 +303 -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 +36 -504
  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 -3452
  60. package/ui/public/app.js +0 -1232
  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 -793
package/README.md CHANGED
@@ -5,150 +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 `0.0.0.0:3737` 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 and renders `http://localhost:3737/` inside a webview.
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:
36
51
 
37
- > **Build note.** `package.json` is deliberately untracked in this repo (see
38
- > `.gitignore`), so the dependency list, the `bin` map, the version and the extension
39
- > manifest are not in git. The published package declares `build` as
40
- > `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`.
41
63
 
42
64
  ## Quick start
43
65
 
44
66
  ```bash
45
67
  npm install
46
68
  npm run build
47
- ENABLE_MCP=1 node dist/ui/server.js # or: ./notify-ui.sh (no MCP)
48
69
  ```
49
70
 
50
- Open <http://localhost:3737>, enable the channels you want, hit the per-channel
51
- **Test** button. Config is written to `~/.notify-mcp/config.json` and re-read on
52
- 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:
53
76
 
54
- 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:
55
87
 
56
88
  ```json
57
89
  {
58
90
  "mcpServers": {
59
- "notify": { "type": "stdio", "command": "npx", "args": ["-y", "omni-notify-mcp"] }
91
+ "notify": { "type": "stdio", "command": "node", "args": ["<globalStorage>/dist/index.mjs"] }
60
92
  }
61
93
  }
62
94
  ```
63
95
 
64
- VS Code / Copilot use `.vscode/mcp.json` with a `servers` key instead of
65
- `mcpServers`; the bundled help page at `http://localhost:3737/help.html` has the exact
66
- snippet for Claude Code, Cursor, VS Code, Claude Desktop, Windsurf, Zed and generic
67
- 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` |
68
105
 
69
- ## 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.
70
111
 
71
- 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
72
115
  entirely server-side.
73
116
 
74
117
  | Tool | Arguments | Behaviour |
75
118
  |---|---|---|
76
- | `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. |
77
- | `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. |
78
- | `poll` | — | Drains queued inbox messages for this session's tag; returns `inbox:empty` when there are none. |
79
- | `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. |
80
- | `get_idle_seconds` | — | Seconds since the last OS keyboard/mouse input, or `-1` where unsupported. Drains the inbox as a side effect. |
81
- | `get_idle_config` | — | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`. Drains the inbox. |
82
- | `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`. |
83
127
  | `update_instructions` | `instructions` (≤4000), `target` = `global`\|`project` | Rewrites a marker-delimited block in `~/.claude/CLAUDE.md` (global) or `./.claude/CLAUDE.md` (project). |
84
- | `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.
85
134
 
86
- **Message size and chunking.** The HTTP server caps `notify.message` at 500
87
- characters and rejects anything longer. The stdio bridge accepts up to 5000, splits it
88
- in `src/chunk.ts` into `(1/N) …` chunks of ≤500 (word-aware where a break lands past
89
- 60 % of the budget), and delivers them in order — so chunking exists **only on the
90
- bridge path**. A bridge caller gets back either `Delivered as N chunks (M chars).` or a
91
- 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.
92
176
 
93
177
  ## Delivery routing
94
178
 
95
- `sendWithRouting` in `ui/messaging/notificationEngine.ts` is the single chokepoint.
96
- Three suppressions run before it, in order: master mute (`muteAll`) drops everything
179
+ Suppression runs first, in order: **master mute** (`muteAll`) drops everything
97
180
  including `high`; a client listed in `disabledClients` is dropped by client id; then
98
- DND drops everything below `high`.
99
-
100
- The **desktop-only** mode kicks in when the web UI reported itself visible in the last
101
- 30 s, or when idle gating is on, no Telegram conversation has been active in the last
102
- 5 minutes, and OS idle time is under the threshold.
103
-
104
- > With `idle.alwaysDesktopWhenActive: false` the desktop is held back too while the user
105
- > is active, so only Slack is attempted. When Slack is off nothing is sent, and the reply
106
- > says `outcome=blocked`, as it does under DND, never `outcome=failed`.
107
-
108
- | Channel | `low` | `normal` | `high` | Under desktop-only |
109
- |---|---|---|---|---|
110
- | desktop | — | ✓ | ✓ | ✓ |
111
- | telegram | — | ✓ | ✓ | — |
112
- | email | ✓ | ✓ | ✓ | — |
113
- | ntfy | ✓ | ✓ | ✓ | — |
114
- | discord | ✓ | ✓ | ✓ | — |
115
- | teams | ✓ | ✓ | ✓ | — |
116
- | slack | ✓ | ✓ | ✓ | **✓ — exempt from idle gating** |
117
- | sms | — | — | ✓ | — |
118
-
119
- `priority: 'high'` bypasses DND and idle gating both. Per-channel failures are
120
- 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.
121
202
 
122
203
  ## Channels
123
204
 
124
205
  Every channel is off until enabled, and a channel whose credentials are incomplete is
125
- 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.
126
208
 
127
209
  | Channel | Config key and fields | Transport |
128
210
  |---|---|---|
129
- | **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`. |
130
- | **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). |
131
- | **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>`. |
132
- | **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. |
133
- | **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. |
134
- | **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. |
135
- | **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. |
136
217
  | **Teams** | `teams` — `enabled`, `webhookUrl` | Adaptive Card v1.2 posted to a Teams *Workflows* webhook. |
137
218
 
138
219
  ## Configuration
139
220
 
140
- `~/.notify-mcp/config.json`, overridable with `NOTIFY_MCP_CONFIG_DIR`. The UI writes
141
- it; secrets are replaced with `••••••••` on read and a masked value posted back is
142
- restored from the on-disk copy rather than overwriting it. Legacy single-destination
143
- fields are migrated on load: `telegram.chatId` → `chatIds[]`, `sms.to` string →
144
- 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.
145
224
 
146
225
  ```json
147
226
  {
148
227
  "muteAll": false,
149
228
  "disabledClients": [],
150
229
  "clientAliases": {},
151
- "desktop": { "enabled": true, "sound": true, "tts": false },
230
+ "desktop": { "enabled": true },
152
231
  "telegram": { "enabled": true, "token": "BOT_TOKEN", "chatIds": ["123456789"] },
153
232
  "sms": {
154
233
  "enabled": false,
@@ -156,11 +235,6 @@ array, and the dead Twilio fields (`accountSid`, `authToken`, `from`) are delete
156
235
  "region": "us-east-1", "originationNumber": "+15550000000",
157
236
  "to": ["+15550000001"]
158
237
  },
159
- "email": {
160
- "enabled": true, "to": "you@gmail.com",
161
- "host": "smtp.gmail.com", "port": 587, "secure": false,
162
- "user": "you@gmail.com", "pass": "GMAIL_APP_PASSWORD"
163
- },
164
238
  "ntfy": { "enabled": false, "topic": "", "serverUrl": "" },
165
239
  "discord": { "enabled": false, "webhookUrl": "", "username": "Claude Notify" },
166
240
  "slack": { "enabled": false, "webhookUrl": "", "botToken": "", "channels": [] },
@@ -173,59 +247,23 @@ array, and the dead Twilio fields (`accountSid`, `authToken`, `from`) are delete
173
247
  }
174
248
  ```
175
249
 
176
- `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
177
254
  ending in `_b64` are base64-decoded at load, and on startup any credential it holds is
178
255
  copied into **empty** fields of `config.json` (never over a value you already set).
179
- See `config.example.json` for a hand-written starting point.
180
-
181
- **This only works if the running server can actually find that file.** The lookup path is
182
- `NOTIFY_MCP_SECRETS_PATH`, defaulting to `<CONFIG_DIR>/notify-secrets.json` — and `CONFIG_DIR`
183
- defaults to `~/.notify-mcp`, a **home-relative** path, not the repo root. A server started via
184
- `npx` or the VS Code extension runs from an npm cache directory with no relationship to any git
185
- checkout, so simply having `notify-secrets.json` committed at the repo root does nothing on its
186
- own (story #438: this sat silently broken for months — every channel stayed disabled and every
187
- `notify` reached nobody, because nothing ever pointed the server at the repo file). Set
188
- `NOTIFY_MCP_SECRETS_PATH` to the repo's `notify-secrets.json` as a **persistent environment
189
- variable** on each machine that has a checkout (`setx NOTIFY_MCP_SECRETS_PATH "C:\path\to\BullseyeNotif\notify-secrets.json"`
190
- on Windows, or export it from your shell profile elsewhere) — set once, it survives reboots and
191
- every future server restart on that machine picks it up. Check `GET /api/config` after a restart:
192
- a channel listed in `notify-secrets.json` with `enabled: true` and its required fields populated
193
- should show `enabled: true` there too — if it still shows `false`, the file was not found.
194
-
195
- ## Inbound — the inbox
196
-
197
- Messages arriving *from* the user (Telegram reply, Slack channel post,
198
- `POST /api/agent/inbox/inject`, Slack Events API) land in one queue and are delivered
199
- through whichever path is available:
200
-
201
- 1. **Parked long-poll waiters** get first refusal — `wait_for_inbox` or
202
- `GET /api/agent/inbox/wait`, delivered as a tool/HTTP result. Message is handed to
203
- the waiters and not queued.
204
- 2. **SSE broadcast** to every `/api/inbox/stream` subscriber whose tag matches. The
205
- stdio bridge is such a subscriber and re-emits each message as a
206
- `notifications/claude/channel` event, which Claude Code (CLI, launched with
207
- `--channels`) surfaces as a synthetic turn.
208
- 3. **File drop** at `~/.notify-mcp/inbox/<iso-ts>[.<tag>].md`, reaped after 24 h. The
209
- Slack-origin drop embeds the exact `curl` needed to answer in-channel.
210
- `.claude/notify-inbox-drain.sh` is the consumer shipped here: it is wired into
211
- `SessionStart`, `UserPromptSubmit` and `Stop`, matches this session's tag, deletes
212
- each file as it reads it, and returns the text as `additionalContext` (or as a
213
- `decision: "block"` reason on `Stop`, which is what lets a message reach an agent
214
- mid-loop). It deliberately no-ops on `PreToolUse`/`PostToolUse`, where Claude Code
215
- discards hook output — draining there would delete a message without delivering it.
216
-
217
- **Tags.** A session declares one via `/mcp?tag=<name>` or `/api/inbox/stream?tag=<name>`.
218
- A user message prefixed `@<tag> ` is routed to that tag only; everything else
219
- broadcasts. The bridge derives its own tag as
220
- `<hostname>-<project>` where `<project>` is `NOTIFY_MCP_TAG`, else the nearest
221
- meaningful folder walking up from `CLAUDE_PROJECT_DIR` (generic names like `claude`,
222
- `code`, `src`, `dist`, `system32` are skipped). When neither variable is set — the VS
223
- Code extension case — a 6-character hash of `CLAUDE_CODE_SESSION_ID` is appended so two
224
- windows sharing a working directory stay distinct. Subagents inherit their parent's
225
- session id, so `/api/clients` folds them into the parent's panel.
226
-
227
- Telegram acknowledges every inbound message in-thread, naming what it did:
228
- 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.
229
267
 
230
268
  ## Slack bus
231
269
 
@@ -233,85 +271,39 @@ A single shared Slack channel works as a cross-machine bus: the server polls
233
271
  `conversations.history` every 2 s, ingests genuine human messages only, answers
234
272
  `clients` / `help` in-channel itself, and routes `@<name> …` / `#<n> …` to the matching
235
273
  connected client. Full architecture, addressing rules and operating notes:
236
- [`docs/SLACK-BUS.md`](docs/SLACK-BUS.md).
237
-
238
- ## Web UI
274
+ [`../docs/BullseyeNotif/SLACK-BUS.md`](../docs/BullseyeNotif/SLACK-BUS.md).
239
275
 
240
- One page at `:3737`, dark, no build step. A master-mute bar on top; a **Delivery
241
- channels** column (desktop, email, telegram, sms, ntfy, discord, slack, teams — each a
242
- collapsible card with a status badge, an enable toggle, a **Test** button and inline
243
- setup instructions); a **System policies** column (Do Not Disturb manual + scheduled
244
- quiet hours with per-day checkboxes; idle gating threshold and the
245
- always-play-desktop-sound toggle). Below them a collapsible panel with two tabs: the
246
- live activity log (SSE, last 500 lines replayed on connect, direction-marked `→ ← ·`)
247
- and **Clients**, which lists every connected session with its panel number and lets you
248
- rename, disable or force-reconnect one. `help.html` is the copy-paste setup page.
276
+ ## The status bell (VS Code extension)
249
277
 
250
- ## 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:
251
281
 
252
- | Path | Purpose |
253
- |---|---|
254
- | `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. |
255
- | `GET /api/inbox/stream[?tag=]` | SSE push of inbound user messages, 20 s keepalive comments. |
256
- | `GET /api/logs` | SSE activity log. |
257
- | `GET/POST /api/config` | Read (masked) / merge-write the config. |
258
- | `GET/POST /api/mute` | Master kill switch. |
259
- | `POST /api/window/register` | Extension → server: `{sessionId, workspaceName, workspacePath}`, so a window's real workspace name can be shown for its bridge session. |
260
- | `GET/POST /api/window/:sessionId/mute` | Mute every client belonging to one editor window. |
261
- | `GET /api/sessions`, `DELETE /api/sessions/:clientId` | Raw MCP session list / drop one. |
262
- | `GET /api/clients` | Connected clients with panel numbering, kinds (`mcp`/`sse`/`waiter`), workspace name and disabled flag. |
263
- | `POST /api/clients/:tag/rename`, `/api/clients/:id/disable` | Persisted display alias / per-client kill switch. |
264
- | `POST /api/clients/:tag/reconnect`, `/api/clients/:tag/panel/:sessionId/reconnect` | Drop every connection for a tag, or just one panel. |
265
- | `POST /api/test/{sound,tts,desktop,telegram,sms,email,ntfy,discord,slack,teams}` | One-shot test send per channel. |
266
- | `GET /api/voices` | English neural voices from msedge-tts, cached 24 h. |
267
- | `GET /api/telegram/chats`, `/api/slack/channels`, `/api/sms/numbers` | Pickers backing the UI. |
268
- | `GET /api/slack/status`, `/api/discord/status`, `/api/gcloud/status` | Connection state for the UI badges. |
269
- | `GET /auth/{google,slack,discord}/start`, `/callback`; `DELETE /auth/{google,slack,discord}` | OAuth connect / disconnect. |
270
- | `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. |
271
- | `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. |
272
- | `POST /api/session/state` | A session reports itself busy/idle so the bus can answer "Claude is busy, ~Ns". |
273
- | `POST /api/slack/events` | Slack Events API receiver; HMAC-verified when `SLACK_SIGNING_SECRET` is set. |
274
- | `POST /api/ui/visibility` | The web UI heartbeats its own visibility; while visible, non-`high` notifications stay desktop-only. |
275
- | `GET /reply/:token`, `POST /reply/:token` | One-shot web reply page for `ask` over email. |
276
- | `GET /v1/health`, `/v1/info` | Liveness (used by the VS Code extension) and version stub. |
277
- | `/:topic/sse`, `/:topic/json`, `PUT`/`POST /:topic`, `/:topic/subscribers` | Built-in ntfy protocol server (also mounted under `/ntfy/…`). |
278
-
279
- `POST /__test__/inject-inbox` and `GET /__test__/slack-clients` exist only when
280
- `NOTIFY_MCP_TEST_ENDPOINTS=1`.
281
-
282
- ## Environment
283
-
284
- | Variable | Read by | Effect |
285
- |---|---|---|
286
- | `PORT` / `NOTIFY_MCP_PORT` | server / bridge | Listen port, default 3737. |
287
- | `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. |
288
- | `NOTIFY_MCP_CONFIG_DIR` | server | Config + inbox-drop root, default `~/.notify-mcp`. |
289
- | `NOTIFY_MCP_TAG` | bridge, `notify-watch.sh`, drain hook | Explicit session tag. |
290
- | `NOTIFY_AGENT_KEY` | server | When set, `/api/agent/*` requires header `x-notify-key`. |
291
- | `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. |
292
- | `NOTIFY_MCP_NO_OPEN`, `BROWSER=none` | server | Suppress the first-run browser pop. |
293
- | `NOTIFY_MCP_TEST_ENDPOINTS` | server | `1` enables the test-only routes, isolates the inbox drop dir, and skips the live Telegram/Slack listeners. |
294
- | `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_SESSION_ID` | bridge, extension | Supplied by the host; drive tag derivation and subagent folding. |
295
-
296
- ## Scripts
297
-
298
- | Script | What it does |
282
+ | Face | Meaning |
299
283
  |---|---|
300
- | `notify-ui.sh` | Build if `dist/ui/server.js` is missing, then run the server in the foreground (**without** `ENABLE_MCP`). |
301
- | `restart.sh` | `npm run build:ui`, kill the running `dist/ui/server.js`, relaunch detached to `/tmp/notify-mcp.log`. |
302
- | `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/`. |
303
- | `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`. |
304
- | `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`). |
305
- | `scripts/bus-startup-task.sh` | Registers `bus-up.sh` with Windows Task Scheduler. |
306
- | `scripts/run-tunnel-loop.sh` | Keeps a localtunnel pointed at the port. |
307
- | `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.
308
301
 
309
302
  ## Tests
310
303
 
311
- `tests/smoke.test.mjs` — end-to-end only. It spawns a real server on a random free
312
- port with an isolated config dir, drives it over real HTTP, and runs the stdio bridge
313
- as a real subprocess; a local capture server stands in for the Slack webhook. Run with
314
- `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
315
307
  existing `dist/`.
316
308
 
317
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