omni-notify-mcp 1.3.292 → 1.3.294

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.
@@ -15,7 +15,7 @@ import { masterMuted } from "./notify.js";
15
15
  import { appendEvent, cliOwns, CONVERSATION_MUTED_MESSAGE, conversationRefusal, editOwed, GatewayError, listConversations, monotonicMs, openConversationOf, openConversationsOf, queueNoticeOf, readEvents, resolveProject, routeAsk, stopTargetOf, sendToConversation, startConversation, updateConversation, } from "./gateway/conversations.js";
16
16
  import { SessionDriver } from "./gateway/sessionDriver.js";
17
17
  import { DEFAULT_PROVIDER } from "./gateway/provider.js";
18
- import { turnEnded } from "./gateway/mcpEvents.js";
18
+ import { resendUndelivered, turnEnded } from "./gateway/mcpEvents.js";
19
19
  export const AGENT_PREFIX = "!";
20
20
  /** A chat door's own words when a message points at a browser CLI console session: the chat does not drive a live CLI, so it says so plainly and names where that session IS reachable. */
21
21
  const CONSOLE_NOT_IN_CHAT = "That is a live CLI console session opened in the browser — this chat does not type into it. Use /terminal in BN, or reach the SAME session from MCP/REST by its id (agent_send types into it, agent_stream streams its console).";
@@ -89,6 +89,7 @@ export class AgentGateway {
89
89
  if (this.recovered)
90
90
  return;
91
91
  this.recovered = true;
92
+ resendUndelivered();
92
93
  for (const c of listConversations())
93
94
  this.recover(c);
94
95
  }
@@ -8,6 +8,10 @@
8
8
  * Subscriptions are kept in one store file beside the conversations (`<config dir>/agent/event-subscriptions.json`), so
9
9
  * a BN restart keeps them. A callback is HTTPS only, every address its host resolves to must be public, the connection
10
10
  * is made to the address that was checked, and a redirect is never followed — the verification challenge included.
11
+ *
12
+ * How far each subscription has been served is a second file, `<config dir>/agent/event-deliveries.json`: per subscription the
13
+ * highest log position whose event reached a final answer (2xx, 413, any other refusal). A completion past it is still owed,
14
+ * and goes again — same event id, same bytes — with the next completion and on every boot, in order.
11
15
  */
12
16
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
13
17
  import { lookup } from "dns/promises";
@@ -17,7 +21,7 @@ import { BlockList, isIP } from "net";
17
21
  import { join } from "path";
18
22
  import { z } from "zod";
19
23
  import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
20
- import { agentDir, GatewayError, readConversation, requireConversationEnabled, writeRecordFile } from "./conversations.js";
24
+ import { agentDir, GatewayError, readConversation, readEvents, requireConversationEnabled, writeRecordFile } from "./conversations.js";
21
25
  /** What the door adds to the capabilities its initialize answers: `events`, a key the SDK's capability type does not name. */
22
26
  export const EVENTS_CAPABILITY = { events: {} };
23
27
  const TURN_COMPLETED = "agent.turn_completed";
@@ -70,6 +74,20 @@ function storedSubscriptions() {
70
74
  return [];
71
75
  return JSON.parse(readFileSync(storePath(), "utf8")).subscriptions;
72
76
  }
77
+ const deliveriesPath = () => join(agentDir(), "event-deliveries.json");
78
+ /** Per subscription id, the highest log position of its conversation that is settled: delivered, or refused for good. */
79
+ function settledPositions() {
80
+ if (!existsSync(deliveriesPath()))
81
+ return {};
82
+ return JSON.parse(readFileSync(deliveriesPath(), "utf8"));
83
+ }
84
+ /** Rewrites the delivery record with `change` applied, keeping only the live subscriptions' entries. */
85
+ function editSettled(change) {
86
+ const settled = settledPositions();
87
+ change(settled);
88
+ const live = new Set(liveSubscriptions().map((s) => s.id));
89
+ writeRecordFile(deliveriesPath(), JSON.stringify(Object.fromEntries(Object.entries(settled).filter(([id]) => live.has(id))), null, 2));
90
+ }
73
91
  /** The subscriptions whose granted lifetime has not passed. */
74
92
  function liveSubscriptions(now = Date.now()) {
75
93
  return storedSubscriptions().filter((s) => s.refreshBefore === null || Date.parse(s.refreshBefore) > now);
@@ -290,6 +308,8 @@ async function subscribe(principal, params) {
290
308
  throw new McpError(CALLBACK_ENDPOINT_ERROR, err.message, { reason: err.reason });
291
309
  }
292
310
  const subscription = { id, principal, name: TURN_COMPLETED, arguments: args, url: delivery.url, secret: delivery.secret, refreshBefore };
311
+ if (settledPositions()[id] === undefined)
312
+ writeRecordFile(deliveriesPath(), JSON.stringify({ ...settledPositions(), [id]: readEvents(args.conversation_id).at(-1)?.n ?? 0 }, null, 2));
293
313
  editStore((live) => [...live.filter((s) => s.id !== id), subscription]);
294
314
  process.stderr.write(`[agent-gateway] mcp events subscribed ${id} conv=${args.conversation_id} refreshBefore=${refreshBefore ?? "none"}\n`);
295
315
  return { id, refreshBefore, cursor: null, truncated: false };
@@ -299,9 +319,13 @@ function unsubscribe(principal, params) {
299
319
  const args = argumentsOf(params.arguments);
300
320
  const { url } = deliveryOf(params.delivery);
301
321
  const id = subscriptionId(principal, url, TURN_COMPLETED, args);
302
- editStore((live) => live.filter((s) => s.id !== id));
322
+ removeSubscription(id);
303
323
  return {};
304
324
  }
325
+ function removeSubscription(id) {
326
+ editStore((live) => live.filter((s) => s.id !== id));
327
+ editSettled((settled) => { delete settled[id]; });
328
+ }
305
329
  /** The conversation switch refuses a new subscription as it refuses a tool call, in the switch's own words. */
306
330
  function requireConversationEnabledAsMcp() {
307
331
  try {
@@ -351,40 +375,74 @@ async function attemptDelivery(subscription, key, eventId, body) {
351
375
  return err;
352
376
  }
353
377
  }
354
- /** Posts one event to one subscription, the same body and id on every try with a fresh signature, until it lands or a final answer comes. */
378
+ /** Posts one event to one subscription, the same body and id on every try with a fresh signature, until it lands or a final answer comes. Settled: the event needs no further send. Unsettled: the tries ran out on a transient failure. */
355
379
  async function deliver(subscription, event) {
356
380
  const eventId = `evt_${createHash("sha256").update(`${subscription.id}\n${event.id}\n${event.turn}`).digest("hex").slice(0, 32)}`;
357
381
  const body = eventBody(eventId, event);
358
382
  const key = signingKey(subscription.secret);
359
383
  for (let attempt = 1;; attempt++) {
360
384
  const outcome = await attemptDelivery(subscription, key, eventId, body);
361
- if (typeof outcome === "number" && outcome >= 200 && outcome < 300)
362
- return logDelivery(eventId, subscription, `delivered HTTP ${outcome} on try ${attempt}`);
385
+ if (typeof outcome === "number" && outcome >= 200 && outcome < 300) {
386
+ logDelivery(eventId, subscription, `delivered HTTP ${outcome} on try ${attempt}`);
387
+ return "settled";
388
+ }
363
389
  if (outcome === 410) {
364
- editStore((live) => live.filter((s) => s.id !== subscription.id));
365
- return logDelivery(eventId, subscription, "the callback answered 410 Gone: the subscription is removed");
390
+ logDelivery(eventId, subscription, "the callback answered 410 Gone: the subscription is removed");
391
+ return "gone";
366
392
  }
367
393
  const answered = typeof outcome === "number" ? `HTTP ${outcome}` : `${outcome.reason}: ${outcome.message}`;
368
- if (!transient(outcome))
369
- return logDelivery(eventId, subscription, `refused by the callback (${answered}) on try ${attempt}; not retried`);
370
- if (attempt === DELIVERY_TRIES)
371
- return logDelivery(eventId, subscription, `not delivered after ${attempt} tries, last ${answered}`);
394
+ if (!transient(outcome)) {
395
+ logDelivery(eventId, subscription, `refused by the callback (${answered}) on try ${attempt}; not retried`);
396
+ return "settled";
397
+ }
398
+ if (attempt === DELIVERY_TRIES) {
399
+ logDelivery(eventId, subscription, `not delivered after ${attempt} tries, last ${answered}; it goes again with the next turn or boot`);
400
+ return "unsettled";
401
+ }
372
402
  await new Promise((r) => setTimeout(r, DELIVERY_FIRST_WAIT_MS << (attempt - 1)));
373
403
  }
374
404
  }
375
- /** Reads the live subscriptions of the turn's conversation and delivers to each, after the session driver's own emit has returned. */
376
- async function reportTurn(event) {
377
- await Promise.resolve();
378
- for (const subscription of liveSubscriptions().filter((s) => s.arguments.conversation_id === event.id)) {
379
- // OWNER: each delivery runs on its own and nothing awaits it — a failure no retry clears is written to the console with its event's conversation, turn and subscription.
380
- void deliver(subscription, event).catch((err) => logDelivery(`turn=${event.turn}`, subscription, `not delivered: ${err instanceof Error ? err.message : String(err)}`));
405
+ /** One subscription's deliveries run one after another, so a retry in flight is never overtaken or doubled by the next turn or a boot. */
406
+ const chains = new Map();
407
+ /** Sends one subscription every completed turn past its settled position, in order, stopping at the first that is not settled. */
408
+ async function sendOwed(subscription) {
409
+ const conversationId = subscription.arguments.conversation_id;
410
+ const settled = settledPositions()[subscription.id];
411
+ const owed = readEvents(conversationId).filter((e) => e.n > settled && (e.kind === "answer" || e.kind === "failed"));
412
+ for (const event of owed) {
413
+ if (!liveSubscriptions().some((s) => s.id === subscription.id))
414
+ return;
415
+ const outcome = await deliver(subscription, { ...event, id: conversationId });
416
+ if (outcome === "unsettled")
417
+ return;
418
+ if (outcome === "gone")
419
+ return removeSubscription(subscription.id);
420
+ if (liveSubscriptions().some((s) => s.id === subscription.id))
421
+ editSettled((settled) => { settled[subscription.id] = event.n; });
381
422
  }
382
423
  }
424
+ function drain(subscription) {
425
+ // OWNER: each subscription's delivery runs on its own and nothing awaits it — a failure no retry clears is written to the console with its subscription, and its undelivered turns stay owed for the next turn or boot.
426
+ const run = (chains.get(subscription.id) ?? Promise.resolve()).then(() => sendOwed(subscription)).catch((err) => { process.stderr.write(`[agent-gateway] mcp events sub=${subscription.id} not delivered: ${err instanceof Error ? err.message : String(err)}\n`); });
427
+ chains.set(subscription.id, run);
428
+ return run;
429
+ }
430
+ /** Boot: every live subscription is sent the completions it was owed when the last process ended. */
431
+ export function resendUndelivered() {
432
+ for (const subscription of liveSubscriptions())
433
+ void drain(subscription);
434
+ }
435
+ /** The subscriptions of one conversation are sent what they are owed, after the session driver's own emit has returned. */
436
+ async function reportTurn(conversationId) {
437
+ await Promise.resolve();
438
+ for (const subscription of liveSubscriptions().filter((s) => s.arguments.conversation_id === conversationId))
439
+ void drain(subscription);
440
+ }
383
441
  /** The session driver's listener: a turn that answered or failed is reported to every live subscription of its conversation. */
384
442
  export function turnEnded(event) {
385
443
  // NULL-OK: field turn is OPTIONAL and therefore it is ok to check for NULL
386
444
  if (event.turn === undefined || (event.kind !== "answer" && event.kind !== "failed"))
387
445
  return;
388
446
  // OWNER: the report runs on its own after the turn is stored and nothing awaits it — a store that cannot be read is written to the console with the conversation and turn.
389
- void reportTurn(event).catch((err) => process.stderr.write(`[agent-gateway] mcp events for conv=${event.id} turn=${event.turn} not reported: ${err instanceof Error ? err.message : String(err)}\n`));
447
+ void reportTurn(event.id).catch((err) => process.stderr.write(`[agent-gateway] mcp events for conv=${event.id} turn=${event.turn} not reported: ${err instanceof Error ? err.message : String(err)}\n`));
390
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omni-notify-mcp",
3
- "version": "1.3.292",
3
+ "version": "1.3.294",
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",