omni-notify-mcp 1.3.291 → 1.3.293
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/agentGateway.js +2 -1
- package/dist/gateway/mcpEvents.js +76 -18
- package/dist/gateway/terminalPage.js +11 -4
- package/package.json +1 -1
package/dist/agentGateway.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
return
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
/**
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
}
|
|
@@ -176,15 +176,22 @@ function turnMirror(meta,turn){
|
|
|
176
176
|
back.onclick=goBack;
|
|
177
177
|
note.textContent="Turn "+turn+" is running — its live output is shown here, and this session's native CLI opens here the moment it ends.";
|
|
178
178
|
var STYLE={thinking:"\\x1b[2;3m",doing:"\\x1b[36m⏺ ",turn:"\\x1b[1m",output:""};
|
|
179
|
-
|
|
180
|
-
function
|
|
179
|
+
var kindOpen;
|
|
180
|
+
function draw(frame){
|
|
181
|
+
var delta=frame.kind==="thinking"||frame.kind==="output",text=String(frame.text).replace(/\\r?\\n/g,"\\r\\n");
|
|
182
|
+
if(delta&&frame.kind===kindOpen){term.write(text);return;}
|
|
183
|
+
term.write((kindOpen?"\\x1b[0m\\r\\n":"")+(STYLE[frame.kind]||"")+text+(delta?"":"\\x1b[0m\\r\\n"));
|
|
184
|
+
kindOpen=delta?frame.kind:undefined;
|
|
185
|
+
}
|
|
186
|
+
function ends(frame){return frame.kind==="turn"&&frame.turn===turn&&(frame.event==="answer"||frame.event==="failed");}
|
|
181
187
|
(async function(){
|
|
182
188
|
var response=await fetch("/v1/conversations/"+meta.id+"/stream",{headers:{authorization:"Bearer "+token},signal:reading.signal});
|
|
183
189
|
if(!response.ok)throw Error((await response.json()).error||"The live output could not be read.");
|
|
184
190
|
var reader=response.body.getReader(),decoder=new TextDecoder(),pending="";
|
|
185
191
|
for(;;){
|
|
186
192
|
var chunk=await reader.read();
|
|
187
|
-
if(
|
|
193
|
+
if(mine!==view)return;
|
|
194
|
+
if(chunk.done){note.textContent="The live output ended before turn "+turn+" did — open this session again to continue.";return;}
|
|
188
195
|
pending+=decoder.decode(chunk.value,{stream:true});
|
|
189
196
|
var blocks=pending.split("\\n\\n");pending=blocks.pop();
|
|
190
197
|
for(var block of blocks){
|
|
@@ -192,7 +199,7 @@ function turnMirror(meta,turn){
|
|
|
192
199
|
var frame=JSON.parse(block.slice(6));draw(frame);
|
|
193
200
|
if(!ends(frame))continue;
|
|
194
201
|
var fresh=await api("GET","/v1/conversations/"+meta.id);
|
|
195
|
-
if(mine===view)openCli(fresh);
|
|
202
|
+
if(mine===view)await openCli(fresh);
|
|
196
203
|
return;
|
|
197
204
|
}
|
|
198
205
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-notify-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.293",
|
|
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",
|