omni-notify-mcp 1.3.306 → 1.3.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -13,8 +13,9 @@ import { spawn } from "child_process";
13
13
  import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from "fs";
14
14
  import { join } from "path";
15
15
  import { fileURLToPath } from "url";
16
- import { ALWAYS_ON_PATH, CONFIG_DIR, PACKAGE_VERSION, startNotifyServer } from "./notify.js";
16
+ import { ALWAYS_ON_PATH, CONFIG_DIR, PACKAGE_VERSION, startNotifyServer, yieldGateway } from "./notify.js";
17
17
  import { startNotifyDoor } from "./gateway/notifyDoor.js";
18
+ import { announceTakeover } from "./shared/webhost/originHandover.js";
18
19
  import { TunnelRunner } from "./gateway/tunnel.js";
19
20
  const HEARTBEAT_MS = 10_000;
20
21
  function beat(pid = process.pid) {
@@ -70,14 +71,22 @@ if (stoppedByHand()) {
70
71
  console.log("[BOOT] BullseyeNotify always-on — a Stop was pressed on this machine; nothing started, and nothing beats until Start is pressed in the panel.");
71
72
  process.exit(0);
72
73
  }
74
+ // The shared hand-over (src/shared/webhost/originHandover.js ensureResidentDoor) puts a later release on the chat door: the new build is started with --takeover over an IPC channel, says it waits, and binds once this build lets the door go.
75
+ const takeover = process.argv.includes("--takeover");
76
+ /** Whether this process writes the heartbeat: the next release only from the moment it holds the chat door, so a takeover that never lands leaves the serving build's beat alone; and a build that let the door go no longer, so the release that took it is the one beat. */
77
+ let beating = !takeover;
73
78
  // The server's own timers are unref'd because a bridge lives as long as its stdin; this process lives until it is stopped.
74
- beat();
75
- /** Each beat reads the Stop first: a press in a window that may not end this process — the boot task's, in session 0 — stops it all the same, within one beat. */
79
+ if (beating)
80
+ beat();
81
+ /** Each tick reads the Stop first, beating or not: a press in a window that may not end this process — the boot task's, in session 0, or one still draining its gateway after a hand-over — stops it all the same, within one tick. */
76
82
  const beatTimer = setInterval(() => {
77
83
  if (stoppedByHand())
78
84
  process.exit(0);
79
- beat();
85
+ if (beating)
86
+ beat();
80
87
  }, HEARTBEAT_MS);
88
+ /** Settles once the chat door has closed after the next release's `POST /shutdown`; set only then, when that release is already running and waiting for the port, so this process starts no successor. */
89
+ let handedOver;
81
90
  // Only the daemon that owns the gateway port runs the connector and publishes its state: a dormant one has nothing for it to reach.
82
91
  const tunnel = new TunnelRunner();
83
92
  startNotifyServer({
@@ -86,9 +95,20 @@ startNotifyServer({
86
95
  tunnel.beat(port);
87
96
  setInterval(() => tunnel.beat(port), HEARTBEAT_MS);
88
97
  },
89
- onSuperseded: replaceWithInstalledBuild,
98
+ onSuperseded: () => (handedOver === undefined ? replaceWithInstalledBuild() : void handedOver.then(() => process.exit(0))),
99
+ });
100
+ startNotifyDoor(() => { beating = true; beat(); }, undefined, {
101
+ version: PACKAGE_VERSION,
102
+ takeover,
103
+ onShutdown: (closed) => {
104
+ beating = false;
105
+ handedOver = closed;
106
+ yieldGateway();
107
+ },
108
+ onTakeoverGaveUp: () => process.exit(1),
90
109
  });
91
- startNotifyDoor(() => { });
110
+ if (takeover)
111
+ announceTakeover();
92
112
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
93
113
  process.on(signal, () => process.exit(0));
94
114
  }
@@ -23,11 +23,14 @@
23
23
  import { createServer, request as httpRequest } from "http";
24
24
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
25
25
  import { createMcpServer, sessionIdentity } from "../notify.js";
26
+ import { answerShutdown, healthBody, TAKEOVER_BIND } from "../shared/webhost/originHandover.js";
26
27
  /** BN's own port block: 7470 is the gateway, so the chat tools take the next free one. */
27
28
  // GATE: the door's port may be moved ONLY under the test gate, so a suite drives a real always-on process on a port it owns; without the gate it is 7475 and no environment can redirect it. vscode-extension/alwaysOn.js probes the same rule.
28
29
  export const NOTIFY_DOOR_PORT = process.env.NOTIFY_MCP_TEST_ENDPOINTS === "1" && Number(process.env.NOTIFY_DOOR_PORT) > 0 ? Number(process.env.NOTIFY_DOOR_PORT) : 7475;
29
30
  export const NOTIFY_DOOR_PATH = "/mcp";
30
31
  const RETRY_MS = 5_000;
32
+ /** The name the always-on process answers `/health` with: the `server` the boot task's supervisor hands BN's port over by (vscode-extension/originSupervisor.js ORIGINS). */
33
+ export const NOTIFY_SERVER = "bullseye-notify";
31
34
  function headerOf(req, name) {
32
35
  const value = req.headers[name];
33
36
  const one = Array.isArray(value) ? value[0] : value;
@@ -76,18 +79,32 @@ function notifyDoorAnswers(port) {
76
79
  * stated, because then notify's chat tools are unreachable machine-wide and nothing else would say so.
77
80
  * Never rejects.
78
81
  */
79
- export function startNotifyDoor(onBound, port = NOTIFY_DOOR_PORT) {
82
+ export function startNotifyDoor(onBound, port = NOTIFY_DOOR_PORT, resident) {
80
83
  let bound = false;
81
84
  let attempting = false;
85
+ let stopping = false;
82
86
  let lastCause = "";
83
87
  let listener = null;
88
+ const giveUpAt = Date.now() + TAKEOVER_BIND.giveUpMs;
84
89
  const say = (line) => { process.stderr.write(`[notify] ${line}\n`); };
85
90
  const attempt = () => {
86
- if (bound || attempting)
91
+ if (bound || attempting || stopping)
87
92
  return;
88
93
  attempting = true;
89
94
  const server = listener = createServer((req, res) => {
90
95
  const path = new URL(req.url ?? "/", "http://door").pathname;
96
+ if (resident !== undefined && path === "/health") {
97
+ res.writeHead(200, { "content-type": "application/json" });
98
+ res.end(healthBody(NOTIFY_SERVER, port, resident.version));
99
+ return;
100
+ }
101
+ if (resident !== undefined && answerShutdown(req, res, server, NOTIFY_SERVER, port, resident.version, (closed) => {
102
+ stopping = true;
103
+ bound = false;
104
+ say(`the next release asked for the chat door on ${port}; build ${resident.version} lets it go`);
105
+ resident.onShutdown(closed);
106
+ }))
107
+ return;
91
108
  if (path !== NOTIFY_DOOR_PATH) {
92
109
  refuse(res, 404, `the notify MCP door answers on ${NOTIFY_DOOR_PATH}`);
93
110
  return;
@@ -113,6 +130,15 @@ export function startNotifyDoor(onBound, port = NOTIFY_DOOR_PORT) {
113
130
  say(`the chat door on ${port} stopped serving (${cause}); rebinding`);
114
131
  return;
115
132
  }
133
+ if (cause === "EADDRINUSE" && resident?.takeover === true) {
134
+ if (Date.now() >= giveUpAt) {
135
+ say(`takeover gave up after ${TAKEOVER_BIND.giveUpMs / 1000}s: the chat door on ${port} was never let go by the release serving it, so build ${resident.version} serves nothing and that release keeps answering`);
136
+ resident.onTakeoverGaveUp();
137
+ return;
138
+ }
139
+ setTimeout(attempt, TAKEOVER_BIND.retryMs);
140
+ return;
141
+ }
116
142
  if (cause === lastCause)
117
143
  return;
118
144
  lastCause = cause;
package/dist/notify.js CHANGED
@@ -2538,6 +2538,14 @@ let ownershipAttempting = false;
2538
2538
  let door;
2539
2539
  const doorConnections = new Set();
2540
2540
  let supersededSaid = false;
2541
+ /** The next release asked for the chat door over `POST /shutdown` (the shared hand-over): this process is superseded by it as it is by a newer manifest, though no manifest beside it moved. */
2542
+ let yielding = false;
2543
+ let reaskOwnership = () => { };
2544
+ /** The always-on process's answer to the hand-over's `POST /shutdown`: the gateway passes to the release that asked, through the same step-aside a newer manifest triggers, so the pollers drain before the port is let go. */
2545
+ export function yieldGateway() {
2546
+ yielding = true;
2547
+ reaskOwnership();
2548
+ }
2541
2549
  /**
2542
2550
  * Once the extension installs a newer build, this process is superseded and the gateway passes to the installed build:
2543
2551
  * a superseded process never takes it while an always-on process beats, and one that holds it steps aside once no
@@ -2548,9 +2556,9 @@ function tryOwnGateway(polling, host) {
2548
2556
  if (ownershipAttempting)
2549
2557
  return;
2550
2558
  const installed = installedVersion();
2551
- if (installed !== PACKAGE_VERSION) {
2559
+ if (yielding || installed !== PACKAGE_VERSION) {
2552
2560
  if (!supersededSaid)
2553
- log("·", "listener", `build ${PACKAGE_VERSION} is superseded by the installed ${installed}; this process hands the gateway to it`);
2561
+ log("·", "listener", `build ${PACKAGE_VERSION} is superseded by ${yielding ? "the release that asked for the chat door" : `the installed ${installed}`}; this process hands the gateway to it`);
2554
2562
  supersededSaid = true;
2555
2563
  if (door !== undefined) {
2556
2564
  if (host.onSuperseded !== undefined || daemonBeats(installed))
@@ -2673,12 +2681,13 @@ export function startNotifyServer(host) {
2673
2681
  // reach nothing but the test's own socket.
2674
2682
  const standInApi = !!(process.env.NOTIFY_TELEGRAM_API || process.env.NOTIFY_SLACK_API || process.env.NOTIFY_DISCORD_API || process.env.NOTIFY_TEAMS_GRAPH_API || process.env.NOTIFY_NTFY_API || process.env.NOTIFY_AWS_API || process.env.NOTIFY_IMAP_API);
2675
2683
  const polling = process.env.NOTIFY_MCP_TEST_ENDPOINTS !== "1" || standInApi;
2684
+ reaskOwnership = () => tryOwnGateway(polling, host);
2676
2685
  tryOwnGateway(polling, host);
2677
2686
  // a process holding the door is asked again when the installed build's manifest is written, the one thing that
2678
2687
  // can supersede it; the beat re-asks only a dormant process, whose owner may be a BN on another machine that no local
2679
2688
  // event announces, and a superseded one waiting for its gateway to fall quiet before it steps aside.
2680
2689
  const ownershipTimer = setInterval(() => {
2681
- if (door === undefined || supersededSaid)
2690
+ if (door === undefined || supersededSaid || yielding)
2682
2691
  tryOwnGateway(polling, host);
2683
2692
  }, OWNERSHIP_RETRY_MS);
2684
2693
  watchNames(dirname(MANIFEST_PATH), [basename(MANIFEST_PATH)], () => tryOwnGateway(polling, host));
@@ -0,0 +1,480 @@
1
+ "use strict";
2
+ const fs = require("fs");
3
+ const http = require("http");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { spawn, spawnSync } = require("child_process");
7
+ const TAKEOVER_READY = "waiting";
8
+ // GATE: a start lock older than this belongs to a window that died mid-start, and the next window takes it over.
9
+ const START_LOCK_STALE_MS = 60_000;
10
+ // GATE: how long a new build is given to reach the wait, and how long it is then given to bind once the old one stops.
11
+ const READY_BOUND_MS = 15_000;
12
+ const BIND_BOUND_MS = 15_000;
13
+ // GATE: how often the waiting build retries the bind — the width of the window the port refuses a new call.
14
+ const BIND_RETRY_MS = 5;
15
+ // GATE: how often a window re-checks that the machine's origin still answers; a crashed origin is started again, under the machine-wide start lock, within this long of any window being open.
16
+ const WATCH_MS = 30_000;
17
+ // GATE: how long a stopping server keeps answering what it already took before it cuts the rest.
18
+ const DRAIN_BOUND_MS = 3_000;
19
+ function ask(port, method, path) {
20
+ return new Promise((resolve) => {
21
+ const req = http.request({ host: "127.0.0.1", port, path, method, timeout: 2_000 }, (res) => {
22
+ let body = "";
23
+ res.setEncoding("utf8");
24
+ res.on("data", (chunk) => (body += chunk));
25
+ res.on("end", () => resolve({ ok: true, status: res.statusCode ?? 0, body }));
26
+ res.on("error", (err) => resolve({ ok: false, refused: false, why: err.message }));
27
+ });
28
+ req.on("timeout", () => {
29
+ req.destroy();
30
+ resolve({ ok: false, refused: false, why: `${method} ${path} timed out` });
31
+ });
32
+ req.on("error", (err) => resolve({ ok: false, refused: err.code === "ECONNREFUSED", why: err.code ?? err.message }));
33
+ req.end();
34
+ });
35
+ }
36
+ /** The machine's start lock for one origin, or undefined when another window holds it: windows racing to start the same origin spawn exactly one process between them. */
37
+ function takeStartLock(origin) {
38
+ const file = path.join(os.homedir(), ".bullseye", "origin-start-locks", `${origin}.lock`);
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ for (let attempt = 0; attempt < 3; attempt++) {
41
+ try {
42
+ fs.writeFileSync(file, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: "wx" });
43
+ return () => fs.rmSync(file, { force: true });
44
+ }
45
+ catch (err) {
46
+ // OWNER: ITER/LOOP — EEXIST means another window holds the lock, which is the answer this function reports — the stale check below decides whether to take over
47
+ if (err.code !== "EEXIST")
48
+ throw err;
49
+ }
50
+ let heldMs;
51
+ try {
52
+ heldMs = Date.now() - fs.statSync(file).mtimeMs;
53
+ }
54
+ catch (err) {
55
+ // OWNER: ITER/LOOP — ENOENT means the holder released between our create and our stat, so the lock is free now — the next attempt takes it
56
+ if (err.code === "ENOENT")
57
+ continue;
58
+ throw err;
59
+ }
60
+ if (heldMs < START_LOCK_STALE_MS)
61
+ return undefined;
62
+ fs.rmSync(file, { force: true });
63
+ }
64
+ return undefined;
65
+ }
66
+ const holdFile = (server, home) => path.join(home, ".bullseye", "origin-desired", `${server}.json`);
67
+ /**
68
+ * Meni's standing intent for one origin: `stopped` once his Stop stands, `running` when none does (no file); a file that cannot be read or parsed, or names any other word, throws, because a failed read is never "running".
69
+ * @param {string} server
70
+ * @param {string} [home] the profile whose hold is read — the boot task names it at install
71
+ * @returns {{ word: "running", at?: number } | { word: "stopped", at: number }}
72
+ */
73
+ function holdOf(server, home = os.homedir()) {
74
+ const file = holdFile(server, home);
75
+ if (!fs.existsSync(file))
76
+ return { word: "running" };
77
+ const read = JSON.parse(fs.readFileSync(file, "utf8"));
78
+ if (read.desired !== "stopped" && read.desired !== "running")
79
+ throw new Error(`${file} names desired ${JSON.stringify(read.desired)}, which is neither "stopped" nor "running"`);
80
+ return { word: read.desired, at: read.at };
81
+ }
82
+ function writeHold(server, desired) {
83
+ const file = holdFile(server, os.homedir());
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
85
+ const temp = `${file}.${process.pid}.tmp`;
86
+ fs.writeFileSync(temp, JSON.stringify({ desired, at: Date.now() }));
87
+ fs.renameSync(temp, file);
88
+ }
89
+ /** Records Meni's Stop BEFORE the origin is asked to stop, so no window, activation, release or boot task starts it again until a Start releases it. */
90
+ const holdStopped = (server) => writeHold(server, "stopped");
91
+ /** Releases Meni's Stop: every starter may start the origin again. */
92
+ const releaseHold = (server) => writeHold(server, "running");
93
+ /** The hand-over's answer for an origin Meni stopped, or undefined when no Stop stands. */
94
+ function heldAnswer(server, port) {
95
+ const hold = holdOf(server);
96
+ if (hold.word !== "stopped")
97
+ return undefined;
98
+ return { state: "held", why: `Meni stopped ${server} on 127.0.0.1:${port} at ${new Date(hold.at).toISOString()} — it stays stopped until Start` };
99
+ }
100
+ /** What answers the port: `serving` with its health, `nothing` (a refused connection), or `unreadable` — a failure to read is never the same answer as a proven absence. */
101
+ async function doorAnswer(port) {
102
+ const answer = await ask(port, "GET", "/health");
103
+ if (!answer.ok) {
104
+ return answer.refused ? { state: "nothing" } : { state: "unreadable", why: `GET /health on 127.0.0.1:${port} failed: ${answer.why}` };
105
+ }
106
+ if (answer.status !== 200) {
107
+ return { state: "unreadable", why: `127.0.0.1:${port}/health answered ${answer.status}, which is not one of our servers` };
108
+ }
109
+ let health;
110
+ try {
111
+ health = JSON.parse(answer.body);
112
+ }
113
+ catch (err) {
114
+ if (!(err instanceof SyntaxError))
115
+ throw err;
116
+ return { state: "unreadable", why: `127.0.0.1:${port}/health answered with a body that is not JSON (${answer.body.slice(0, 120)})` };
117
+ }
118
+ if (typeof health.server !== "string" || typeof health.version !== "string") {
119
+ return { state: "unreadable", why: `127.0.0.1:${port}/health answered without naming itself: ${answer.body.slice(0, 120)}` };
120
+ }
121
+ return { state: "serving", health: { server: health.server, version: health.version, pid: typeof health.pid === "number" ? health.pid : undefined } };
122
+ }
123
+ /** Whether the new process reached the point of waiting for the port — its one promise that it can take over. */
124
+ function waitingForThePort(child, boundMs) {
125
+ return new Promise((resolve) => {
126
+ let settled = false;
127
+ const done = (ready) => {
128
+ if (settled)
129
+ return;
130
+ settled = true;
131
+ clearTimeout(bound);
132
+ child.removeAllListeners("message");
133
+ child.removeAllListeners("exit");
134
+ child.removeAllListeners("error");
135
+ resolve(ready);
136
+ };
137
+ const bound = setTimeout(() => done(false), boundMs);
138
+ child.on("message", (message) => {
139
+ if (message?.takeover === TAKEOVER_READY)
140
+ done(true);
141
+ });
142
+ child.once("exit", () => done(false));
143
+ child.once("error", () => done(false));
144
+ });
145
+ }
146
+ /** Whether `server` answers the port at `wanted` or any later release, within the bound. */
147
+ async function servesUntil(port, server, wanted, boundMs) {
148
+ const deadline = Date.now() + boundMs;
149
+ for (;;) {
150
+ const answer = await doorAnswer(port);
151
+ if (answer.state === "serving" && answer.health.server === server && !laterThan(wanted, answer.health.version))
152
+ return true;
153
+ if (Date.now() >= deadline)
154
+ return false;
155
+ await new Promise((resolve) => setTimeout(resolve, 25));
156
+ }
157
+ }
158
+ /** Whether anything answers the door within the bound — the window that holds the start lock is bringing it up. */
159
+ async function answersUntil(port, boundMs) {
160
+ const deadline = Date.now() + boundMs;
161
+ for (;;) {
162
+ if ((await doorAnswer(port)).state !== "nothing")
163
+ return true;
164
+ if (Date.now() >= deadline)
165
+ return false;
166
+ await new Promise((resolve) => setTimeout(resolve, 25));
167
+ }
168
+ }
169
+ /** Whether `candidate` is a later release than `serving` — a daemon is never taken DOWN to an older build. */
170
+ function laterThan(candidate, serving) {
171
+ const a = candidate.split(".").map(Number);
172
+ const b = serving.split(".").map(Number);
173
+ for (let i = 0; i < 3; i++) {
174
+ if (a[i] !== b[i])
175
+ return a[i] > b[i];
176
+ }
177
+ return false;
178
+ }
179
+ /**
180
+ * The process LISTENING on the port, asked of the operating system — read only for a build that predates `POST /shutdown`.
181
+ *
182
+ * Windows' `netstat -ano` is NOT that source on the Windows 11 boxes this fleet runs: every PID column reads 0 and a
183
+ * 127.0.0.1 listener is missing from the table altogether, so a pre-fix daemon could never be named and the first release
184
+ * to meet one left it serving. The connection table behind `Get-NetTCPConnection` does name it; a listening socket is the
185
+ * row whose REMOTE PORT is 0 (its State column reads empty there, so State is never the filter). `netstat` stays as the
186
+ * fallback for a host whose PowerShell refuses.
187
+ */
188
+ function ownerOfPort(port) {
189
+ if (process.platform !== "win32") {
190
+ const asked = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { encoding: "utf8", windowsHide: true });
191
+ const printed = asked.stdout ?? "";
192
+ const absence = asked.status === 1 && printed.trim() === "";
193
+ if (asked.error !== undefined || (asked.status !== 0 && !absence)) {
194
+ console.error(`[bullseye] could not ask this machine which process listens on 127.0.0.1:${port}: ${asked.error?.message ?? `the query exited ${asked.status}`}`);
195
+ return undefined;
196
+ }
197
+ const pid = Number(/^\s*(\d+)/m.exec(printed)?.[1]);
198
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
199
+ }
200
+ const asked = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", `(Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.RemotePort -eq 0 } | Select-Object -First 1 -ExpandProperty OwningProcess)`], { encoding: "utf8", windowsHide: true });
201
+ const owner = Number((asked.stdout ?? "").trim());
202
+ if (asked.status === 0 && Number.isInteger(owner) && owner > 0)
203
+ return owner;
204
+ return ownerOfPortByNetstat(port);
205
+ }
206
+ /** The fallback reading of the same fact, for a Windows host whose PowerShell will not answer. */
207
+ function ownerOfPortByNetstat(port) {
208
+ const asked = spawnSync("netstat", ["-ano"], { encoding: "utf8", windowsHide: true });
209
+ const printed = asked.stdout ?? "";
210
+ if (asked.error !== undefined || asked.status !== 0) {
211
+ console.error(`[bullseye] could not ask this machine which process listens on 127.0.0.1:${port}: ${asked.error?.message ?? `the query exited ${asked.status}`}`);
212
+ return undefined;
213
+ }
214
+ const found = /LISTENING\s+(\d+)/i.exec(printed.split("\n").find((line) => new RegExp(`[:.]${port}\\s+\\S+\\s+LISTENING\\s+`, "i").test(line)) ?? "");
215
+ const pid = Number(found?.[1]);
216
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
217
+ }
218
+ /** Stop the server holding the port: over its own door first (which drains), and only a build older than that door is ended as a process, the pid read from the operating system and never from a remembered hint. */
219
+ async function stopServing(port, health, siblingPid) {
220
+ const answered = await ask(port, "POST", "/shutdown");
221
+ if (answered.ok && answered.status >= 200 && answered.status < 300) {
222
+ return true;
223
+ }
224
+ const listening = ownerOfPort(port);
225
+ if (listening === undefined || listening === siblingPid || (health.pid !== undefined && health.pid !== listening)) {
226
+ console.error(`[bullseye] the resident server on 127.0.0.1:${port} (${health.version}) does not answer the stop door, and ${listening === undefined ? "no process was found holding the port" : listening === siblingPid ? `the port is now held by this release's own waiting process (pid ${listening})` : `the port is held by pid ${listening} while /health named ${health.pid}`} — nothing was signalled, so it keeps serving this release`);
227
+ return false;
228
+ }
229
+ const now = await doorAnswer(port);
230
+ if (now.state !== "serving" || now.health.version !== health.version || (now.health.pid !== undefined && now.health.pid !== listening)) {
231
+ console.error(`[bullseye] 127.0.0.1:${port} no longer answers ${health.version} on pid ${listening} — it now answers ${now.state === "serving" ? `${now.health.version}${now.health.pid === undefined ? "" : ` on pid ${now.health.pid}`}` : now.state}, so nothing was signalled`);
232
+ return false;
233
+ }
234
+ try {
235
+ process.kill(listening, "SIGTERM");
236
+ return true;
237
+ }
238
+ catch (err) {
239
+ if (err.code !== "ESRCH" && err.code !== "EPERM")
240
+ throw err;
241
+ console.error(`[bullseye] the resident server on 127.0.0.1:${port} (${health.version}, pid ${listening}) could not be stopped: ${err.message}`);
242
+ return false;
243
+ }
244
+ }
245
+ /** The release a bundle at `<root>/dist/<file>` serves: the `version` of the `<root>/package.json` beside it, which is where its `/health` reads it from. */
246
+ function bundleRelease(bundle) {
247
+ return JSON.parse(fs.readFileSync(path.join(path.dirname(bundle), "..", "package.json"), "utf8")).version;
248
+ }
249
+ /**
250
+ * ONE hand-over protocol for every resident MCP origin (BB, FV, DA). Start the origin when nothing answers its port
251
+ * (under the machine's start lock, re-checked once held), and hand the port to `wanted` when the one answering is an
252
+ * older release of the same server: the new build is spawned FIRST with `--takeover` and waits for the port, and only
253
+ * once it has said so is the old process asked to stop over its own `POST /shutdown`, which drains what it is
254
+ * answering before it closes the listener. A new build that never reached the wait leaves the old server serving.
255
+ * `door` = { port, server, wanted, spawnDoor(takeover) -> ChildProcess } where spawnDoor
256
+ * starts the bundle detached (with an IPC channel and `--takeover` when asked). Returns what happened:
257
+ * serving | left alone | failed | held (Meni's Stop stands, so nothing is started).
258
+ */
259
+ async function ensureResidentDoor(door) {
260
+ const { port, server, wanted } = door;
261
+ const answer = await doorAnswer(port);
262
+ if (answer.state === "nothing") {
263
+ const heldBefore = heldAnswer(server, port);
264
+ if (heldBefore !== undefined)
265
+ return heldBefore;
266
+ const release = takeStartLock(server);
267
+ if (release === undefined) {
268
+ if (!(await answersUntil(port, READY_BOUND_MS)))
269
+ return { state: "failed", why: `another window was starting ${server} on 127.0.0.1:${port} but nothing answered it` };
270
+ return ensureResidentDoor(door);
271
+ }
272
+ try {
273
+ if ((await doorAnswer(port)).state === "nothing") {
274
+ const held = heldAnswer(server, port);
275
+ if (held !== undefined)
276
+ return held;
277
+ door.spawnDoor(false).unref();
278
+ return (await servesUntil(port, server, wanted, READY_BOUND_MS)) ? { state: "serving", version: wanted } : { state: "failed", why: `${wanted} was started on 127.0.0.1:${port} but never answered` };
279
+ }
280
+ }
281
+ finally {
282
+ release();
283
+ }
284
+ return ensureResidentDoor(door);
285
+ }
286
+ if (answer.state === "unreadable") {
287
+ return { state: "left alone", why: answer.why };
288
+ }
289
+ const { health } = answer;
290
+ if (health.server !== server) {
291
+ return { state: "left alone", why: `127.0.0.1:${port} is answered by "${health.server}" (${health.version}), not by ${server}` };
292
+ }
293
+ if (!laterThan(wanted, health.version)) {
294
+ return { state: "serving", version: health.version };
295
+ }
296
+ const release = takeStartLock(server);
297
+ if (release === undefined) {
298
+ return (await servesUntil(port, server, wanted, READY_BOUND_MS + 2 * BIND_BOUND_MS)) ? { state: "serving", version: wanted } : { state: "failed", why: `another window was putting ${wanted} on 127.0.0.1:${port} but it never answered` };
299
+ }
300
+ try {
301
+ const held = await doorAnswer(port);
302
+ if (held.state === "serving" && held.health.server === server && laterThan(wanted, held.health.version)) {
303
+ const stopped = heldAnswer(server, port);
304
+ if (stopped !== undefined)
305
+ return stopped;
306
+ return await takeThePort(door, held.health);
307
+ }
308
+ }
309
+ finally {
310
+ release();
311
+ }
312
+ return ensureResidentDoor(door);
313
+ }
314
+ /** The swap itself, run under the start lock so one window at a time puts a release on the port. */
315
+ async function takeThePort(door, health) {
316
+ const { port, server, wanted } = door;
317
+ const next = door.spawnDoor(true);
318
+ if (!(await waitingForThePort(next, READY_BOUND_MS))) {
319
+ next.kill();
320
+ next.unref();
321
+ return { state: "failed", why: `the new build ${wanted} never reached the point of waiting for 127.0.0.1:${port}; ${health.version} is still serving` };
322
+ }
323
+ next.unref();
324
+ if (!(await stopServing(port, health, next.pid))) {
325
+ if (await servesUntil(port, server, wanted, BIND_BOUND_MS)) {
326
+ console.error(`[bullseye] the resident server on 127.0.0.1:${port} took this release: ${health.version} → ${wanted}`);
327
+ return { state: "serving", version: wanted };
328
+ }
329
+ return { state: "failed", why: `${health.version} would not stop, so ${wanted} was not put on 127.0.0.1:${port}` };
330
+ }
331
+ if (!(await servesUntil(port, server, wanted, BIND_BOUND_MS))) {
332
+ const now = await doorAnswer(port);
333
+ return { state: "failed", why: `${wanted} was started and ${health.version} was stopped, but 127.0.0.1:${port} now answers ${now.state === "serving" ? now.health.version : now.state}; start the extension again to take the port` };
334
+ }
335
+ console.error(`[bullseye] the resident server on 127.0.0.1:${port} took this release: ${health.version} → ${wanted}`);
336
+ return { state: "serving", version: wanted };
337
+ }
338
+ /** The per-day file the resident origin on `port` writes its stderr to, under the user's local app data. */
339
+ function residentLogFile(port, now = new Date()) {
340
+ const base = process.env.LOCALAPPDATA ?? path.join(os.homedir(), ".local", "state");
341
+ const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
342
+ return path.join(base, "Bullseye", "origin-logs", `origin-${port}-${day}.log`);
343
+ }
344
+ /** Starts `bundle` as the origin on `port`: detached and windowless so closing the window leaves it up, run as node by Electron's own binary, stderr appended to its per-day log, with an IPC channel and `--takeover` when it is the next release. */
345
+ function spawnBundle(bundle, port, cwd, takeover) {
346
+ const file = residentLogFile(port);
347
+ fs.mkdirSync(path.dirname(file), { recursive: true });
348
+ const log = fs.openSync(file, "a");
349
+ try {
350
+ return spawn(process.execPath, [bundle, `--http=${port}`, ...(takeover ? ["--takeover"] : [])], {
351
+ detached: true,
352
+ stdio: takeover ? ["ignore", "ignore", log, "ipc"] : ["ignore", "ignore", log],
353
+ windowsHide: true,
354
+ cwd,
355
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
356
+ });
357
+ }
358
+ finally {
359
+ fs.closeSync(log);
360
+ }
361
+ }
362
+ /**
363
+ * Keeps the machine's one origin up and current for as long as this window lives: an immediate pass, then one per
364
+ * `WATCH_MS`. `origin` = { port, start() -> Promise } where `start` runs the hand-over and throws when it did not
365
+ * serve. A failure is reported once per distinct reason through `report`. Returns what the window disposes on close.
366
+ */
367
+ function keepDoor(origin, report) {
368
+ let lastWhy;
369
+ let running = false;
370
+ const pass = async () => {
371
+ if (running)
372
+ return;
373
+ running = true;
374
+ let why;
375
+ try {
376
+ await origin.start();
377
+ }
378
+ catch (err) {
379
+ // OWNER: the watch timer is this pass's trigger root — a pass that throws is reported once and the next tick tries again
380
+ if (!(err instanceof Error))
381
+ throw err;
382
+ why = err.message;
383
+ }
384
+ finally {
385
+ running = false;
386
+ }
387
+ if (why !== undefined && why !== lastWhy)
388
+ report(`the MCP origin on 127.0.0.1:${origin.port} is not serving this release — ${why}`);
389
+ lastWhy = why;
390
+ };
391
+ void pass();
392
+ const timer = setInterval(() => void pass(), WATCH_MS);
393
+ return { dispose: () => clearInterval(timer) };
394
+ }
395
+ /** The body of `GET /health` a resident origin answers — the pid lets the next release stop a build that predates `/shutdown`. */
396
+ function healthBody(server, port, version) {
397
+ return JSON.stringify({ ok: true, server, port, pid: process.pid, version });
398
+ }
399
+ /**
400
+ * Answers `POST /shutdown` on an origin's own door: replies 202 (flushed before the listener closes, since the asker
401
+ * is the process taking the port), then drains and exits. A request carrying an `Origin` header comes from a web page
402
+ * and is refused. Returns true when the request was this door's. A process with more to let go of than this door (BN's
403
+ * gateway pollers) passes `onStopping`: it is handed the listener's close in place of the exit, and ends itself.
404
+ */
405
+ function answerShutdown(req, res, httpServer, server, port, version, onStopping) {
406
+ if ((req.url ?? "/").split("?")[0] !== "/shutdown")
407
+ return false;
408
+ if (req.headers.origin !== undefined) {
409
+ res.writeHead(403, { "content-type": "application/json" });
410
+ res.end(JSON.stringify({ error: "POST /shutdown is for the next release of this server, never for a web page" }));
411
+ return true;
412
+ }
413
+ if (req.method !== "POST") {
414
+ res.writeHead(405, { "content-type": "application/json", allow: "POST" });
415
+ res.end(JSON.stringify({ error: `${req.method} /shutdown is not this server's door; POST it to stop a process the next release replaces` }));
416
+ return true;
417
+ }
418
+ res.writeHead(202, { "content-type": "application/json" });
419
+ res.end(JSON.stringify({ ok: true, stopping: true, server, port, version }), () => {
420
+ const closed = new Promise((resolve) => httpServer.close(() => resolve()));
421
+ httpServer.closeIdleConnections();
422
+ if (onStopping !== undefined) {
423
+ setTimeout(() => httpServer.closeAllConnections(), DRAIN_BOUND_MS).unref();
424
+ onStopping(closed);
425
+ return;
426
+ }
427
+ void closed.then(() => process.exit(0));
428
+ setTimeout(() => {
429
+ httpServer.closeAllConnections();
430
+ process.exit(0);
431
+ }, DRAIN_BOUND_MS).unref();
432
+ });
433
+ return true;
434
+ }
435
+ /** The next release's one promise to the process that started it: it now waits for the port (what takeThePort waits for), and the IPC channel is let go. */
436
+ function announceTakeover() {
437
+ if (process.connected)
438
+ process.send({ takeover: TAKEOVER_READY }, () => process.disconnect());
439
+ }
440
+ /** How the next release waits for the port: it retries the bind every `retryMs` and gives up once `giveUpMs` has passed since it started. */
441
+ const TAKEOVER_BIND = { retryMs: BIND_RETRY_MS, giveUpMs: READY_BOUND_MS };
442
+ /**
443
+ * Binds `httpServer` on the loopback port. Without `--takeover` the first refusal means another process already
444
+ * serves it and this one leaves quietly. With `--takeover` it is the next release: it tells its parent over IPC that it
445
+ * waits, retries the bind until the old build closes its listener, and gives up only when the bound passes. While
446
+ * Meni's Stop stands the process exits before it binds, whoever started it — a window, a release or a boot-task
447
+ * supervisor that predates the hold.
448
+ */
449
+ function listenWithHandover(httpServer, { server, port, version, onServing }) {
450
+ const takeover = process.argv.includes("--takeover");
451
+ if (!takeover && holdOf(server).word === "stopped") {
452
+ console.error(`[${server}-mcp] Meni stopped ${server} on 127.0.0.1:${port} — it stays stopped until Start, so this process serves nothing`);
453
+ process.exit(0);
454
+ }
455
+ const giveUpAt = Date.now() + READY_BOUND_MS;
456
+ httpServer.on("error", (err) => {
457
+ if (err.code !== "EADDRINUSE")
458
+ throw err;
459
+ if (!takeover) {
460
+ console.error(`[${server}-mcp] 127.0.0.1:${port} is already served by another process — nothing to start here`);
461
+ process.exit(0);
462
+ }
463
+ if (Date.now() >= giveUpAt) {
464
+ console.error(`[${server}-mcp] takeover gave up after ${READY_BOUND_MS}ms: 127.0.0.1:${port} was never released by the process serving it — this release is NOT serving, and the one that is has been left to answer`);
465
+ process.exit(1);
466
+ }
467
+ void doorAnswer(port).then((now) => {
468
+ if (now.state === "serving" && now.health.version === version)
469
+ process.exit(0);
470
+ setTimeout(() => httpServer.listen(port, "127.0.0.1"), BIND_RETRY_MS);
471
+ });
472
+ });
473
+ if (takeover) {
474
+ console.error(`[${server}-mcp] takeover: waiting for 127.0.0.1:${port}, which an earlier release holds`);
475
+ announceTakeover();
476
+ }
477
+ httpServer.once("listening", onServing);
478
+ httpServer.listen(port, "127.0.0.1");
479
+ }
480
+ module.exports = { takeStartLock, bundleRelease, spawnBundle, residentLogFile, keepDoor, ensureResidentDoor, doorAnswer, healthBody, answerShutdown, announceTakeover, TAKEOVER_BIND, listenWithHandover, holdOf, holdStopped, releaseHold };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.3.306",
3
+ "version": "1.3.307",
4
4
  "description": "Reach your AI agents from anywhere — and let them reach you. One stdio process serves the whole tool surface to Claude Code, GitHub Copilot Chat and Codex: desktop, Telegram, Slack, ntfy, Discord, Teams and SMS are configured in one file, and agents notify you when long work finishes or a decision is needed, ask you a question and wait for your answer, and receive and reply to the messages you send them — with Do Not Disturb and idle detection deciding what actually gets through.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",