omni-notify-mcp 1.3.34 → 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.
- package/README.md +202 -212
- package/assets/bn-logo-180.png +0 -0
- package/assets/bn-logo-192.png +0 -0
- package/assets/bn-logo-32.png +0 -0
- package/assets/bn-logo-512.png +0 -0
- package/assets/bn-logo-64.png +0 -0
- package/assets/bn-logo.svg +25 -0
- package/assets/favicon.ico +0 -0
- package/config.example.json +20 -16
- package/dist/agentGateway.js +293 -0
- package/dist/daemon.js +95 -0
- package/dist/gateway/agentStream.js +88 -0
- package/dist/gateway/chatPage.js +1240 -0
- package/dist/gateway/claudeProvider.js +376 -0
- package/dist/gateway/codexArchive.js +69 -0
- package/dist/gateway/codexProvider.js +32 -0
- package/dist/gateway/conversations.js +709 -0
- package/dist/gateway/copilotProvider.js +172 -0
- package/dist/gateway/iconData.js +8 -0
- package/dist/gateway/iconDoor.js +23 -0
- package/dist/gateway/lanProbe.js +86 -0
- package/dist/gateway/mcpDoor.js +303 -0
- package/dist/gateway/nativeTerminal.js +403 -0
- package/dist/gateway/nativeTerminalWorker.js +168 -0
- package/dist/gateway/notifyDoor.js +139 -0
- package/dist/gateway/oauthDoor.js +345 -0
- package/dist/gateway/panel.js +189 -0
- package/dist/gateway/panelPage.js +213 -0
- package/dist/gateway/passkeyClient.js +47 -0
- package/dist/gateway/passkeyDoor.js +945 -0
- package/dist/gateway/provider.js +21 -0
- package/dist/gateway/providers.js +15 -0
- package/dist/gateway/publicAccess.js +374 -0
- package/dist/gateway/restDoor.js +302 -0
- package/dist/gateway/sessionDriver.js +1188 -0
- package/dist/gateway/terminalDoor.js +226 -0
- package/dist/gateway/terminalPage.js +290 -0
- package/dist/gateway/tunnel.js +133 -0
- package/dist/index.js +37 -629
- package/dist/messaging/chatInbound.js +405 -0
- package/dist/messaging/deliveryVerdict.js +52 -0
- package/dist/messaging/emailInbound.js +292 -0
- package/dist/messaging/inboundPayloads.js +41 -0
- package/dist/{ui/messaging → messaging}/notificationEngine.js +12 -10
- package/dist/{ui/messaging → messaging}/telegramError.js +1 -1
- package/dist/notify.js +2709 -0
- package/dist/shared/agentSession.js +1099 -0
- package/dist/shared/webhost/bullseyeWebHost.js +1182 -0
- package/dist/shared/webhost/openedWorkspaces.js +62 -0
- package/dist/shared/webhost/package.json +1 -0
- package/dist/shared/webhost/webviewShim.js +271 -0
- package/package.json +35 -26
- package/scripts/idle-check.ps1 +1 -1
- package/scripts/shared/ownerGuard.cs +665 -0
- package/scripts/shared/ownerGuard.ps1 +31 -0
- package/scripts/shared/ownerGuard.sh +64 -0
- package/assets/screenshots/help-page.png +0 -0
- package/assets/screenshots/main-ui.png +0 -0
- package/dist/ui/server.js +0 -3785
- package/ui/public/app.js +0 -1379
- package/ui/public/assets/logo.svg +0 -111
- package/ui/public/help.css +0 -189
- package/ui/public/help.html +0 -272
- package/ui/public/help.js +0 -30
- package/ui/public/index.html +0 -538
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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://
|
|
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
|
-
<
|
|
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
|
-
##
|
|
40
|
+
## A manager of remote CLI sessions
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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": "
|
|
91
|
+
"notify": { "type": "stdio", "command": "node", "args": ["<globalStorage>/dist/index.mjs"] }
|
|
62
92
|
}
|
|
63
93
|
}
|
|
64
94
|
```
|
|
65
95
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
| `ask` | `question
|
|
80
|
-
| `poll` | — | Drains queued inbox messages for this session
|
|
81
|
-
| `wait_for_inbox` | `timeout_seconds` (5–55, default 50) |
|
|
82
|
-
| `get_idle_seconds` | — | How long since the last
|
|
83
|
-
| `get_idle_config` | — | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`.
|
|
84
|
-
| `
|
|
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`
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
in
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
|
113
|
-
|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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`, `
|
|
132
|
-
| **Telegram** | `telegram` — `enabled`, `token`, `chatIds[]` | `sendMessage` per chat id
|
|
133
|
-
| **
|
|
134
|
-
| **
|
|
135
|
-
| **
|
|
136
|
-
| **
|
|
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`.
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
[
|
|
239
|
-
|
|
240
|
-
## Web UI
|
|
274
|
+
[`../docs/BullseyeNotif/SLACK-BUS.md`](../docs/BullseyeNotif/SLACK-BUS.md).
|
|
241
275
|
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
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
|
-
|
|
|
303
|
-
|
|
|
304
|
-
|
|
|
305
|
-
|
|
|
306
|
-
|
|
|
307
|
-
|
|
|
308
|
-
|
|
|
309
|
-
|
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|