@achieveai/hitl-mcp-server 2.9.6 → 2.11.2
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/cli.d.ts +31 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +133 -5
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/git-context.d.ts +53 -2
- package/dist/git-context.d.ts.map +1 -1
- package/dist/git-context.js +118 -18
- package/dist/git-context.js.map +1 -1
- package/dist/host-settings.d.ts +68 -0
- package/dist/host-settings.d.ts.map +1 -0
- package/dist/host-settings.js +136 -0
- package/dist/host-settings.js.map +1 -0
- package/dist/identity.d.ts +20 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +35 -0
- package/dist/identity.js.map +1 -0
- package/dist/mcp-server.d.ts +65 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +446 -65
- package/dist/mcp-server.js.map +1 -1
- package/dist/ntfy-transport.d.ts +293 -13
- package/dist/ntfy-transport.d.ts.map +1 -1
- package/dist/ntfy-transport.js +765 -88
- package/dist/ntfy-transport.js.map +1 -1
- package/dist/payload.d.ts +80 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +135 -0
- package/dist/payload.js.map +1 -0
- package/dist/plan-diff.d.ts +29 -0
- package/dist/plan-diff.d.ts.map +1 -0
- package/dist/plan-diff.js +138 -0
- package/dist/plan-diff.js.map +1 -0
- package/dist/plan-file.d.ts +24 -0
- package/dist/plan-file.d.ts.map +1 -0
- package/dist/plan-file.js +98 -0
- package/dist/plan-file.js.map +1 -0
- package/dist/plan-review.d.ts +54 -0
- package/dist/plan-review.d.ts.map +1 -0
- package/dist/plan-review.js +110 -0
- package/dist/plan-review.js.map +1 -0
- package/dist/setup.d.ts +22 -5
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +52 -15
- package/dist/setup.js.map +1 -1
- package/dist/snapshot-store.d.ts +104 -0
- package/dist/snapshot-store.d.ts.map +1 -0
- package/dist/snapshot-store.js +209 -0
- package/dist/snapshot-store.js.map +1 -0
- package/dist/types.d.ts +144 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/package.json +64 -61
package/dist/ntfy-transport.js
CHANGED
|
@@ -1,16 +1,315 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import path from 'path';
|
|
1
5
|
import { encrypt, decrypt, isEncryptedEnvelope } from './crypto.js';
|
|
2
6
|
import { shouldChunk, splitIntoChunks } from './chunking.js';
|
|
7
|
+
import { assertNoChunk } from './payload.js';
|
|
8
|
+
/**
|
|
9
|
+
* Pull ntfy's own attachment metadata off a raw event.
|
|
10
|
+
*
|
|
11
|
+
* Shape confirmed live against ntfy.sh:
|
|
12
|
+
* {"name":"…","type":"application/octet-stream","size":5000,
|
|
13
|
+
* "expires":1786514937,"url":"https://ntfy.sh/file/qurRQchLV1Fb.bin"}
|
|
14
|
+
*
|
|
15
|
+
* This is plaintext metadata outside our encryption — `name` echoes the
|
|
16
|
+
* Filename header, so senders must use random hex there and never a real path.
|
|
17
|
+
*/
|
|
18
|
+
export function parseAttachment(ntfyEvent) {
|
|
19
|
+
const att = ntfyEvent?.attachment;
|
|
20
|
+
if (!att || typeof att.url !== 'string')
|
|
21
|
+
return undefined;
|
|
22
|
+
return {
|
|
23
|
+
name: typeof att.name === 'string' ? att.name : '',
|
|
24
|
+
url: att.url,
|
|
25
|
+
type: typeof att.type === 'string' ? att.type : undefined,
|
|
26
|
+
size: typeof att.size === 'number' ? att.size : undefined,
|
|
27
|
+
expires: typeof att.expires === 'number' ? att.expires : undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// -----------------------------------------------------------
|
|
31
|
+
// ntfy error classification
|
|
32
|
+
//
|
|
33
|
+
// Verified live: the error envelope is
|
|
34
|
+
// {"code":42901,"http":429,"error":"…","link":"…"}
|
|
35
|
+
// where `code` is <http-status><2-digit subcode>. An oversized X-Message is
|
|
36
|
+
// answered by the nginx in front of ntfy with an HTML body instead, so nothing
|
|
37
|
+
// here may assume the body parses as JSON.
|
|
38
|
+
// -----------------------------------------------------------
|
|
39
|
+
/** 429 subcodes that a delay can actually clear. */
|
|
40
|
+
const RETRYABLE_NTFY_CODES = new Set([
|
|
41
|
+
42901, // too many requests — burst / visitor throttle
|
|
42
|
+
42911, // too many new topics, please wait
|
|
43
|
+
]);
|
|
44
|
+
/** Codes where retrying is pointless; the reason names how to actually recover. */
|
|
45
|
+
const FATAL_NTFY_CODES = new Map([
|
|
46
|
+
[42908, 'daily ntfy message quota reached — resets at UTC midnight, or self-host'],
|
|
47
|
+
[42905, 'daily ntfy attachment bandwidth reached — resets at UTC midnight, or self-host'],
|
|
48
|
+
[42903, 'too many active ntfy subscriptions for this IP'],
|
|
49
|
+
[41301, 'attachment rejected by ntfy as oversized'],
|
|
50
|
+
]);
|
|
51
|
+
/** Raised when publishing failed and retrying cannot help. */
|
|
52
|
+
export class NtfyPublishError extends Error {
|
|
53
|
+
status;
|
|
54
|
+
code;
|
|
55
|
+
constructor(message, status, code) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.status = status;
|
|
58
|
+
this.code = code;
|
|
59
|
+
this.name = 'NtfyPublishError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Raised when ntfy refuses the subscription itself.
|
|
64
|
+
*
|
|
65
|
+
* `retryable` decides whether the reconnect loop keeps trying. A 403 from an
|
|
66
|
+
* auth-required topic or a mistyped `ntfyUrl` never becomes a 200, and
|
|
67
|
+
* reconnecting forever at 30 s hides a diagnosable cause behind a call that
|
|
68
|
+
* simply never returns.
|
|
69
|
+
*/
|
|
70
|
+
export class NtfySubscribeError extends Error {
|
|
71
|
+
status;
|
|
72
|
+
retryable;
|
|
73
|
+
code;
|
|
74
|
+
constructor(message, status, retryable, code) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.status = status;
|
|
77
|
+
this.retryable = retryable;
|
|
78
|
+
this.code = code;
|
|
79
|
+
this.name = 'NtfySubscribeError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Raised when the X-Message header would exceed the proxy's header buffer. */
|
|
83
|
+
export class XMessageTooLargeError extends Error {
|
|
84
|
+
byteLength;
|
|
85
|
+
constructor(byteLength) {
|
|
86
|
+
super(`Plan metadata header is ${byteLength} bytes, over the ${X_MESSAGE_MAX_BYTES}-byte limit. ` +
|
|
87
|
+
`ntfy sits behind an nginx whose header buffer is 8 KB; this cap is half of it.`);
|
|
88
|
+
this.byteLength = byteLength;
|
|
89
|
+
this.name = 'XMessageTooLargeError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Raised when an attachment URL 404s — ntfy expires attachments after 3 h. */
|
|
93
|
+
export class AttachmentExpiredError extends Error {
|
|
94
|
+
url;
|
|
95
|
+
constructor(url) {
|
|
96
|
+
super(`Attachment has expired or was never stored: ${url}`);
|
|
97
|
+
this.url = url;
|
|
98
|
+
this.name = 'AttachmentExpiredError';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Half of nginx's default 8 KB `large_client_header_buffers`. Measured live:
|
|
103
|
+
* 7317 bytes still returned 200, 16317 returned an nginx 400. Our encrypted
|
|
104
|
+
* metadata runs ~600–900 bytes, so this is a wide margin against proxies with
|
|
105
|
+
* smaller defaults rather than a tight fit.
|
|
106
|
+
*/
|
|
107
|
+
export const X_MESSAGE_MAX_BYTES = 4096;
|
|
108
|
+
/**
|
|
109
|
+
* Decide whether a failed publish is worth retrying.
|
|
110
|
+
*
|
|
111
|
+
* Unknown 429s are treated as retryable — the burst throttle is the common case
|
|
112
|
+
* and it clears on its own. Everything else in the 4xx range is a request the
|
|
113
|
+
* server will reject identically forever.
|
|
114
|
+
*/
|
|
115
|
+
export function classifyNtfyError(status, body) {
|
|
116
|
+
let code;
|
|
117
|
+
let detail = body.trim().slice(0, 500);
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(body);
|
|
120
|
+
if (typeof parsed.code === 'number')
|
|
121
|
+
code = parsed.code;
|
|
122
|
+
if (typeof parsed.error === 'string')
|
|
123
|
+
detail = parsed.error;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// nginx answers an oversized header with HTML. Keep the raw text.
|
|
127
|
+
}
|
|
128
|
+
if (code !== undefined) {
|
|
129
|
+
const fatal = FATAL_NTFY_CODES.get(code);
|
|
130
|
+
if (fatal) {
|
|
131
|
+
return { retryable: false, code, message: `${fatal} (ntfy ${code}: ${detail})` };
|
|
132
|
+
}
|
|
133
|
+
if (RETRYABLE_NTFY_CODES.has(code)) {
|
|
134
|
+
return { retryable: true, code, message: `ntfy ${code}: ${detail}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (status === 429) {
|
|
138
|
+
return {
|
|
139
|
+
retryable: true,
|
|
140
|
+
code,
|
|
141
|
+
message: `ntfy returned 429 with no recognizable code — treating as a burst throttle. ` +
|
|
142
|
+
`If this persists it may be the daily quota, which resets at UTC midnight. Body: ${detail}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (status >= 500) {
|
|
146
|
+
return { retryable: true, code, message: `ntfy server error ${status}: ${detail}` };
|
|
147
|
+
}
|
|
148
|
+
return { retryable: false, code, message: `ntfy rejected the request (${status}): ${detail}` };
|
|
149
|
+
}
|
|
150
|
+
const DEFAULT_RETRY = {
|
|
151
|
+
maxAttempts: 5,
|
|
152
|
+
totalBudgetMs: 60_000,
|
|
153
|
+
initialDelayMs: 500,
|
|
154
|
+
maxDelayMs: 8_000,
|
|
155
|
+
};
|
|
156
|
+
const DEFAULT_SUBSCRIPTION = {
|
|
157
|
+
initialBackoffMs: 1_000,
|
|
158
|
+
maxBackoffMs: 30_000,
|
|
159
|
+
healthyConnectionMs: 5_000,
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Records what this process is still waiting for, at `~/.hitl/pending/<pid>.json`.
|
|
163
|
+
*
|
|
164
|
+
* `waitForAnswer` streams forward-only, so a server that dies mid-wait loses the
|
|
165
|
+
* answer permanently even though ntfy holds it for 12 h. Persisting the
|
|
166
|
+
* outstanding IDs lets a restarted process recognize a retried review as the
|
|
167
|
+
* same one and pull the already-submitted response out of the cache instead of
|
|
168
|
+
* asking the human again (D-8).
|
|
169
|
+
*/
|
|
170
|
+
export class PendingStore {
|
|
171
|
+
pid;
|
|
172
|
+
dir;
|
|
173
|
+
file;
|
|
174
|
+
entries = [];
|
|
175
|
+
constructor(pid = process.pid) {
|
|
176
|
+
this.pid = pid;
|
|
177
|
+
this.dir = path.join(process.env.HITL_HOME ?? path.join(homedir(), '.hitl'), 'pending');
|
|
178
|
+
this.file = path.join(this.dir, `${pid}.json`);
|
|
179
|
+
}
|
|
180
|
+
record(entry) {
|
|
181
|
+
this.entries = this.entries.filter((e) => e.id !== entry.id);
|
|
182
|
+
this.entries.push(entry);
|
|
183
|
+
this.flush();
|
|
184
|
+
}
|
|
185
|
+
clear(id) {
|
|
186
|
+
const before = this.entries.length;
|
|
187
|
+
this.entries = this.entries.filter((e) => e.id !== id);
|
|
188
|
+
if (this.entries.length !== before)
|
|
189
|
+
this.flush();
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Find a still-unanswered review of the same plan at the same content hash,
|
|
193
|
+
* left behind by this or an earlier process. Reusing its reviewId means a
|
|
194
|
+
* review window that is still open on the human's device resolves this call.
|
|
195
|
+
*
|
|
196
|
+
* Entries this process is *currently* waiting on are excluded. Those are not
|
|
197
|
+
* abandoned work to resume, they are a concurrent call — handing back its
|
|
198
|
+
* reviewId would point two live waits at one response.
|
|
199
|
+
*/
|
|
200
|
+
findResumableReview(planId, snapshotHash) {
|
|
201
|
+
const stillWaiting = new Set(this.entries.map((e) => e.id));
|
|
202
|
+
for (const entry of this.readAll()) {
|
|
203
|
+
if (stillWaiting.has(entry.id))
|
|
204
|
+
continue;
|
|
205
|
+
if (entry.kind === 'plan_review' && entry.planId === planId && entry.snapshotHash === snapshotHash) {
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
/** Every entry on disk, this process's and any predecessor's. */
|
|
212
|
+
readAll() {
|
|
213
|
+
if (!existsSync(this.dir))
|
|
214
|
+
return [];
|
|
215
|
+
const all = [];
|
|
216
|
+
for (const name of readdirSync(this.dir)) {
|
|
217
|
+
if (!name.endsWith('.json'))
|
|
218
|
+
continue;
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(readFileSync(path.join(this.dir, name), 'utf8'));
|
|
221
|
+
if (Array.isArray(parsed))
|
|
222
|
+
all.push(...parsed);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// A truncated file from a killed process is not worth failing a call over.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return all;
|
|
229
|
+
}
|
|
230
|
+
flush() {
|
|
231
|
+
try {
|
|
232
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
233
|
+
if (this.entries.length === 0) {
|
|
234
|
+
if (existsSync(this.file))
|
|
235
|
+
unlinkSync(this.file);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
writeFileSync(this.file, JSON.stringify(this.entries, null, 2) + '\n', {
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
mode: 0o600,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
// Losing durability degrades D-8 to "no resume"; it must not fail the call.
|
|
245
|
+
console.error(`Could not persist pending waits for pid ${this.pid}: ${err}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Cap on remembered messageIds. Only guards against a reconnect replaying events. */
|
|
250
|
+
const SEEN_IDS_LIMIT = 512;
|
|
251
|
+
/** A bounded FIFO set of messageIds, so a long-lived process cannot grow one without limit. */
|
|
252
|
+
class RecentIds {
|
|
253
|
+
limit;
|
|
254
|
+
set = new Set();
|
|
255
|
+
order = [];
|
|
256
|
+
constructor(limit) {
|
|
257
|
+
this.limit = limit;
|
|
258
|
+
}
|
|
259
|
+
has(id) {
|
|
260
|
+
return this.set.has(id);
|
|
261
|
+
}
|
|
262
|
+
add(id) {
|
|
263
|
+
if (this.set.has(id))
|
|
264
|
+
return;
|
|
265
|
+
this.set.add(id);
|
|
266
|
+
this.order.push(id);
|
|
267
|
+
if (this.order.length > this.limit) {
|
|
268
|
+
const evicted = this.order.shift();
|
|
269
|
+
if (evicted)
|
|
270
|
+
this.set.delete(evicted);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
3
274
|
/**
|
|
4
275
|
* Transport layer for communicating with ntfy.sh.
|
|
5
276
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
277
|
+
* One SSE subscription is shared by every outstanding wait and ref-counted, so
|
|
278
|
+
* a ReviewPlan and an AskUserQuestion running in the same agent turn resolve
|
|
279
|
+
* over a single connection (C-9) instead of clobbering each other's
|
|
280
|
+
* AbortController. The subscription reconnects on clean end as well as on
|
|
281
|
+
* error, resuming from the last event timestamp (C-8).
|
|
8
282
|
*/
|
|
9
283
|
export class NtfyTransport {
|
|
10
284
|
config;
|
|
11
|
-
|
|
12
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Registrations are keyed by a private counter, never by the caller's key.
|
|
287
|
+
*
|
|
288
|
+
* Two calls can legitimately arrive on the same key — two ReviewPlan calls
|
|
289
|
+
* that resolve to the same reviewId, most obviously. Keying the map by the
|
|
290
|
+
* caller's string let the second `set` silently displace the first, leaving
|
|
291
|
+
* a promise nobody could ever settle, and let either one's cleanup delete
|
|
292
|
+
* the other's live registration.
|
|
293
|
+
*/
|
|
294
|
+
waiters = new Map();
|
|
295
|
+
watchers = new Map();
|
|
296
|
+
nextRegistrationId = 1;
|
|
297
|
+
subscriptionAbort = null;
|
|
298
|
+
subscriptionRefs = 0;
|
|
299
|
+
/** Unix seconds. Where a reconnect resumes from. */
|
|
300
|
+
lastEventTs = 0;
|
|
301
|
+
/** Delivered to a waiter. Gates both re-delivery and the replay backstop. */
|
|
302
|
+
consumedIds = new RecentIds(SEEN_IDS_LIMIT);
|
|
303
|
+
/** Shown to a watcher. Kept apart so observing never suppresses replay (H3). */
|
|
304
|
+
observedIds = new RecentIds(SEEN_IDS_LIMIT);
|
|
305
|
+
retry;
|
|
306
|
+
subscriptionPolicy;
|
|
307
|
+
pending;
|
|
308
|
+
constructor(config, options = {}) {
|
|
13
309
|
this.config = config;
|
|
310
|
+
this.retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
311
|
+
this.subscriptionPolicy = { ...DEFAULT_SUBSCRIPTION, ...options.subscription };
|
|
312
|
+
this.pending = new PendingStore();
|
|
14
313
|
}
|
|
15
314
|
/** Full URL for the ntfy topic. */
|
|
16
315
|
get topicUrl() {
|
|
@@ -18,8 +317,11 @@ export class NtfyTransport {
|
|
|
18
317
|
return `${base}/${this.config.topicId}`;
|
|
19
318
|
}
|
|
20
319
|
/**
|
|
21
|
-
* Publish
|
|
22
|
-
*
|
|
320
|
+
* Publish one of the four shipping message types.
|
|
321
|
+
*
|
|
322
|
+
* Their wire format is frozen: plain JSON, optionally encrypted, chunked when
|
|
323
|
+
* oversized. The type signature is what keeps a plan message off this path —
|
|
324
|
+
* a chunked plan_review would break the one-message-per-review guarantee.
|
|
23
325
|
*/
|
|
24
326
|
async publish(msg) {
|
|
25
327
|
let body;
|
|
@@ -37,16 +339,149 @@ export class NtfyTransport {
|
|
|
37
339
|
await this.publishRaw(JSON.stringify(chunk));
|
|
38
340
|
}
|
|
39
341
|
}
|
|
342
|
+
/**
|
|
343
|
+
* Publish a plan-review message. Never chunks.
|
|
344
|
+
*
|
|
345
|
+
* When `attachmentCipher` is given, the outer message rides along in the
|
|
346
|
+
* `X-Message` header of the attachment PUT, so a plan of any size is still
|
|
347
|
+
* exactly one ntfy message (C-1).
|
|
348
|
+
*/
|
|
349
|
+
async publishPlan(msg, attachmentCipher) {
|
|
350
|
+
const body = this.config.encryptionKey
|
|
351
|
+
? encrypt(JSON.stringify(msg), this.config.encryptionKey)
|
|
352
|
+
: JSON.stringify(msg);
|
|
353
|
+
assertNoChunk(body);
|
|
354
|
+
if (attachmentCipher === undefined) {
|
|
355
|
+
await this.publishRaw(body);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
await this.uploadAttachment(attachmentCipher, body);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Publish a `sender_identity` companion message. Never chunks.
|
|
362
|
+
*
|
|
363
|
+
* Sibling of `publishPlan` — same encrypt -> assertNoChunk ->
|
|
364
|
+
* publishRaw/uploadAttachment shape, but for the small decoration message
|
|
365
|
+
* that follows a question or notification rather than a `PlanMessage`.
|
|
366
|
+
*/
|
|
367
|
+
async publishSenderIdentity(msg, attachmentCipher) {
|
|
368
|
+
const body = this.config.encryptionKey
|
|
369
|
+
? encrypt(JSON.stringify(msg), this.config.encryptionKey)
|
|
370
|
+
: JSON.stringify(msg);
|
|
371
|
+
assertNoChunk(body);
|
|
372
|
+
if (attachmentCipher === undefined) {
|
|
373
|
+
await this.publishRaw(body);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
await this.uploadAttachment(attachmentCipher, body);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* PUT the payload as an ntfy attachment with the outer message in `X-Message`.
|
|
380
|
+
*
|
|
381
|
+
* The filename is random hex: ntfy echoes it verbatim as plaintext metadata
|
|
382
|
+
* on the event, outside our encryption, so a real path there would leak the
|
|
383
|
+
* absolute location of the plan (F-9).
|
|
384
|
+
*/
|
|
385
|
+
async uploadAttachment(cipher, outerJson) {
|
|
386
|
+
const headerBytes = Buffer.byteLength(outerJson, 'utf8');
|
|
387
|
+
if (headerBytes > X_MESSAGE_MAX_BYTES) {
|
|
388
|
+
throw new XMessageTooLargeError(headerBytes);
|
|
389
|
+
}
|
|
390
|
+
// JSON.stringify emits no literal newlines, but a stray CR/LF in a header
|
|
391
|
+
// is a request-splitting bug rather than a size error — check it explicitly.
|
|
392
|
+
if (/[\r\n]/.test(outerJson)) {
|
|
393
|
+
throw new NtfyPublishError('Plan metadata contains a line break and cannot be sent as a header', 0);
|
|
394
|
+
}
|
|
395
|
+
await this.fetchWithRetry(this.topicUrl, {
|
|
396
|
+
method: 'PUT',
|
|
397
|
+
headers: {
|
|
398
|
+
Filename: `${randomBytes(12).toString('hex')}.bin`,
|
|
399
|
+
'X-Message': outerJson,
|
|
400
|
+
},
|
|
401
|
+
body: cipher,
|
|
402
|
+
}, 'upload plan attachment');
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Fetch an attachment's bytes.
|
|
406
|
+
*
|
|
407
|
+
* A 404 is its own error: ntfy expires attachments after 3 h while keeping
|
|
408
|
+
* messages for 12 h, so a replayed review routinely points at a dead URL and
|
|
409
|
+
* the caller must be able to say "expired" rather than "failed" (C-4/C-12).
|
|
410
|
+
*/
|
|
411
|
+
async downloadAttachment(ref, signal) {
|
|
412
|
+
const response = await fetch(ref.url, { signal });
|
|
413
|
+
if (response.status === 404 || response.status === 410) {
|
|
414
|
+
throw new AttachmentExpiredError(ref.url);
|
|
415
|
+
}
|
|
416
|
+
if (!response.ok) {
|
|
417
|
+
throw new NtfyPublishError(`Failed to download attachment ${ref.url}: ${response.status} ${response.statusText}`, response.status);
|
|
418
|
+
}
|
|
419
|
+
return await response.text();
|
|
420
|
+
}
|
|
40
421
|
/** POST a single raw body string to the ntfy topic. */
|
|
41
422
|
async publishRaw(body) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
423
|
+
await this.fetchWithRetry(this.topicUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }, 'publish message');
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Send a request, retrying only the failures a delay can clear.
|
|
427
|
+
*
|
|
428
|
+
* Capped at 5 attempts and a 60 s total budget: past that the cause is
|
|
429
|
+
* structural, and spinning against a daily quota that resets at UTC midnight
|
|
430
|
+
* would block the agent for hours (C-13). `Retry-After` is honoured when
|
|
431
|
+
* present — it was absent on every probed ntfy response, so it is read
|
|
432
|
+
* opportunistically and never required.
|
|
433
|
+
*/
|
|
434
|
+
async fetchWithRetry(url, init, describe) {
|
|
435
|
+
const startedAt = Date.now();
|
|
436
|
+
let delayMs = this.retry.initialDelayMs;
|
|
437
|
+
let lastMessage = '';
|
|
438
|
+
for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt++) {
|
|
439
|
+
let response;
|
|
440
|
+
try {
|
|
441
|
+
response = await fetch(url, init);
|
|
442
|
+
}
|
|
443
|
+
catch (err) {
|
|
444
|
+
// A transport-level failure (DNS, refused, reset) is worth a retry.
|
|
445
|
+
lastMessage = `${err.message}`;
|
|
446
|
+
if (attempt === this.retry.maxAttempts || Date.now() - startedAt >= this.retry.totalBudgetMs) {
|
|
447
|
+
throw new NtfyPublishError(`Failed to ${describe}: ${lastMessage}`, 0);
|
|
448
|
+
}
|
|
449
|
+
await sleep(nextDelay(delayMs, this.retry.maxDelayMs));
|
|
450
|
+
delayMs = Math.min(delayMs * 2, this.retry.maxDelayMs);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (response.ok)
|
|
454
|
+
return response;
|
|
455
|
+
const body = await safeText(response);
|
|
456
|
+
const verdict = classifyNtfyError(response.status, body);
|
|
457
|
+
lastMessage = verdict.message;
|
|
458
|
+
console.error(`ntfy ${describe} attempt ${attempt} failed: ${verdict.message}`);
|
|
459
|
+
if (!verdict.retryable) {
|
|
460
|
+
throw new NtfyPublishError(`Failed to ${describe}: ${verdict.message}`, response.status, verdict.code);
|
|
461
|
+
}
|
|
462
|
+
// NOT idempotent on its own. A retryable 429 is safe — ntfy rejected the
|
|
463
|
+
// message, so nothing was stored — but a 5xx is ambiguous: a proxy can
|
|
464
|
+
// return 502/504 after ntfy has already accepted and stored the message,
|
|
465
|
+
// and this then publishes it a second time. The same is true of the
|
|
466
|
+
// transport-level retry above for a reset that arrives after the request
|
|
467
|
+
// was fully sent.
|
|
468
|
+
//
|
|
469
|
+
// What makes that safe is on the client, not here: `SeenIds` in
|
|
470
|
+
// `client/src-tauri/src/ntfy.rs` de-dupes on our own `messageId` across
|
|
471
|
+
// both the cache replay and the live stream, and a retry re-sends
|
|
472
|
+
// byte-identical bytes. Remove that de-dupe and this retry starts
|
|
473
|
+
// double-delivering dialogs — there is nothing at this site that would
|
|
474
|
+
// stop it. The residual cost today is a doubled attachment upload
|
|
475
|
+
// against the daily bandwidth quota; the orphan expires in 3 h.
|
|
476
|
+
const elapsed = Date.now() - startedAt;
|
|
477
|
+
if (attempt === this.retry.maxAttempts || elapsed >= this.retry.totalBudgetMs)
|
|
478
|
+
break;
|
|
479
|
+
const retryAfterMs = parseRetryAfter(response.headers?.get?.('retry-after'));
|
|
480
|
+
const wait = Math.min(retryAfterMs ?? nextDelay(delayMs, this.retry.maxDelayMs), Math.max(0, this.retry.totalBudgetMs - elapsed));
|
|
481
|
+
await sleep(wait);
|
|
482
|
+
delayMs = Math.min(delayMs * 2, this.retry.maxDelayMs);
|
|
49
483
|
}
|
|
484
|
+
throw new NtfyPublishError(`Failed to ${describe} after ${this.retry.maxAttempts} attempts: ${lastMessage}`, 429);
|
|
50
485
|
}
|
|
51
486
|
/** @deprecated Use publish() instead */
|
|
52
487
|
async publishQuestion(msg) {
|
|
@@ -57,50 +492,255 @@ export class NtfyTransport {
|
|
|
57
492
|
return this.publish(msg);
|
|
58
493
|
}
|
|
59
494
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
495
|
+
* Wait for the first message satisfying `match`.
|
|
496
|
+
*
|
|
497
|
+
* `key` identifies the waiter so overlapping calls cannot clobber one another
|
|
498
|
+
* (C-9). The cache is replayed once at registration, after the live stream is
|
|
499
|
+
* already attached, so a response published while this process was down or
|
|
500
|
+
* reconnecting still resolves the call (D-8).
|
|
62
501
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
502
|
+
* `signal` releases the SSE connection and the waiter within one tick of
|
|
503
|
+
* cancellation, which is what stops a stopped tool call leaking a connection
|
|
504
|
+
* for the life of the process (D-9).
|
|
66
505
|
*/
|
|
67
|
-
|
|
506
|
+
waitFor(key, match, signal) {
|
|
68
507
|
return new Promise((resolve, reject) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (timeout && timeout > 0) {
|
|
73
|
-
timer = setTimeout(() => {
|
|
74
|
-
this.abortController?.abort();
|
|
75
|
-
reject(new Error('Dialog timeout'));
|
|
76
|
-
}, timeout);
|
|
508
|
+
if (signal?.aborted) {
|
|
509
|
+
reject(new AbortedWaitError(key, signal.reason));
|
|
510
|
+
return;
|
|
77
511
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
512
|
+
const registrationId = this.nextRegistrationId++;
|
|
513
|
+
let settled = false;
|
|
514
|
+
const finish = (fn) => {
|
|
515
|
+
if (settled)
|
|
516
|
+
return;
|
|
517
|
+
settled = true;
|
|
518
|
+
// By registration, never by key: another call may be waiting on the
|
|
519
|
+
// same key, and deleting theirs would hang them forever.
|
|
520
|
+
this.waiters.delete(registrationId);
|
|
521
|
+
signal?.removeEventListener('abort', onAbort);
|
|
522
|
+
this.releaseSubscription();
|
|
523
|
+
fn();
|
|
81
524
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
}).catch((err) => {
|
|
91
|
-
cleanup();
|
|
92
|
-
if (!signal.aborted) {
|
|
93
|
-
reject(err);
|
|
94
|
-
}
|
|
525
|
+
const onAbort = () => finish(() => reject(new AbortedWaitError(key, signal?.reason)));
|
|
526
|
+
signal?.addEventListener('abort', onAbort);
|
|
527
|
+
this.waiters.set(registrationId, {
|
|
528
|
+
key,
|
|
529
|
+
match,
|
|
530
|
+
resolve: (received) => finish(() => resolve(received)),
|
|
531
|
+
reject: (err) => finish(() => reject(err)),
|
|
95
532
|
});
|
|
96
|
-
|
|
97
|
-
|
|
533
|
+
this.acquireSubscription();
|
|
534
|
+
// Replay after the stream is attached, so nothing can slip through the
|
|
535
|
+
// gap between the poll returning and the stream connecting.
|
|
536
|
+
void this.replayCache(match)
|
|
537
|
+
.then((received) => {
|
|
538
|
+
if (received)
|
|
539
|
+
this.deliver(received);
|
|
540
|
+
})
|
|
541
|
+
.catch((err) => {
|
|
542
|
+
console.error(`Cache replay for ${key} failed: ${err}`);
|
|
98
543
|
});
|
|
99
544
|
});
|
|
100
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Observe matching messages without consuming them, until the returned
|
|
548
|
+
* function is called.
|
|
549
|
+
*
|
|
550
|
+
* Unlike a waiter, a watcher fires repeatedly and only sees what no waiter
|
|
551
|
+
* took — which is exactly the shape of "a second device submitted after the
|
|
552
|
+
* first one already won" (D-5). It holds the subscription open while
|
|
553
|
+
* registered.
|
|
554
|
+
*/
|
|
555
|
+
watch(key, match, handle) {
|
|
556
|
+
const registrationId = this.nextRegistrationId++;
|
|
557
|
+
this.watchers.set(registrationId, { key, match, handle });
|
|
558
|
+
this.acquireSubscription();
|
|
559
|
+
let released = false;
|
|
560
|
+
return () => {
|
|
561
|
+
if (released)
|
|
562
|
+
return;
|
|
563
|
+
released = true;
|
|
564
|
+
this.watchers.delete(registrationId);
|
|
565
|
+
this.releaseSubscription();
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Subscribe and wait for an answer to a specific question.
|
|
570
|
+
*
|
|
571
|
+
* A thin wrapper over waitFor so the shipping AskUserQuestion path behaves
|
|
572
|
+
* exactly as before, minus the timeout (D-1).
|
|
573
|
+
*/
|
|
574
|
+
async waitForAnswer(questionId, signal) {
|
|
575
|
+
const received = await this.waitFor(`answer:${questionId}`, (msg) => msg.type === 'answer' && msg.questionId === questionId, signal);
|
|
576
|
+
return received.msg;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Poll ntfy's message cache for an already-published match.
|
|
580
|
+
*
|
|
581
|
+
* `since=all` reaches back the full 12 h retention, which is the window in
|
|
582
|
+
* which a response can have been submitted while this process was gone.
|
|
583
|
+
*/
|
|
584
|
+
async replayCache(match, since = 'all') {
|
|
585
|
+
const url = `${this.topicUrl}/json?poll=1&since=${since}`;
|
|
586
|
+
const response = await fetch(url, { headers: { Accept: 'application/x-ndjson' } });
|
|
587
|
+
if (!response.ok) {
|
|
588
|
+
throw new NtfyPublishError(`ntfy cache poll failed: ${response.status} ${response.statusText}`, response.status);
|
|
589
|
+
}
|
|
590
|
+
for (const line of (await response.text()).split('\n')) {
|
|
591
|
+
const received = this.parseEventLine(line);
|
|
592
|
+
if (received && match(received.msg))
|
|
593
|
+
return received;
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
/** Join the shared subscription, starting it if this is the first waiter. */
|
|
598
|
+
acquireSubscription() {
|
|
599
|
+
this.subscriptionRefs++;
|
|
600
|
+
if (this.subscriptionAbort)
|
|
601
|
+
return;
|
|
602
|
+
if (this.lastEventTs === 0)
|
|
603
|
+
this.lastEventTs = Math.floor(Date.now() / 1000);
|
|
604
|
+
const controller = new AbortController();
|
|
605
|
+
this.subscriptionAbort = controller;
|
|
606
|
+
void this.runSubscription(controller.signal);
|
|
607
|
+
}
|
|
608
|
+
/** Leave the shared subscription, closing it when the last waiter goes. */
|
|
609
|
+
releaseSubscription() {
|
|
610
|
+
this.subscriptionRefs = Math.max(0, this.subscriptionRefs - 1);
|
|
611
|
+
if (this.subscriptionRefs === 0 && this.subscriptionAbort) {
|
|
612
|
+
this.subscriptionAbort.abort();
|
|
613
|
+
this.subscriptionAbort = null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Keep one stream attached until every waiter is gone.
|
|
618
|
+
*
|
|
619
|
+
* A clean stream end is treated exactly like an error: ntfy closes idle
|
|
620
|
+
* connections routinely, and the previous code resolved the read loop on
|
|
621
|
+
* close so the wait simply never completed (C-8).
|
|
622
|
+
*/
|
|
623
|
+
async runSubscription(signal) {
|
|
624
|
+
let backoff = this.subscriptionPolicy.initialBackoffMs;
|
|
625
|
+
while (!signal.aborted) {
|
|
626
|
+
const connectedAt = Date.now();
|
|
627
|
+
try {
|
|
628
|
+
await this.startSSEListener(`${this.topicUrl}/json?since=${this.lastEventTs}`, signal, (msg, attachment) => this.deliver({ msg, attachment }));
|
|
629
|
+
}
|
|
630
|
+
catch (err) {
|
|
631
|
+
if (signal.aborted)
|
|
632
|
+
return;
|
|
633
|
+
// A subscription ntfy will never accept is not something to retry at
|
|
634
|
+
// 30 s forever. Tell everyone waiting why, so the agent surfaces the
|
|
635
|
+
// cause instead of blocking until the host gives up (H2).
|
|
636
|
+
if (err instanceof NtfySubscribeError && !err.retryable) {
|
|
637
|
+
console.error(`ntfy subscription rejected, giving up: ${err.message}`);
|
|
638
|
+
this.failAllWaiters(err);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
console.error(`ntfy subscription error, reconnecting: ${err.message}`);
|
|
642
|
+
}
|
|
643
|
+
if (signal.aborted)
|
|
644
|
+
return;
|
|
645
|
+
if (Date.now() - connectedAt >= this.subscriptionPolicy.healthyConnectionMs) {
|
|
646
|
+
backoff = this.subscriptionPolicy.initialBackoffMs;
|
|
647
|
+
}
|
|
648
|
+
await sleep(backoff, signal);
|
|
649
|
+
backoff = Math.min(backoff * 2, this.subscriptionPolicy.maxBackoffMs);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Hand a message to the first waiter that wants it, ignoring replays.
|
|
654
|
+
*
|
|
655
|
+
* A message is marked consumed only when a waiter actually takes it. A
|
|
656
|
+
* watcher merely observing one must not suppress the `replayCache` backstop:
|
|
657
|
+
* on the D-8 resume path the response arrives while the watcher is attached
|
|
658
|
+
* but before the waiter registers, and marking it seen there would both tell
|
|
659
|
+
* the human their submission was lost and hide it from replay, hanging the
|
|
660
|
+
* agent that was about to wait for it (H3).
|
|
661
|
+
*/
|
|
662
|
+
deliver(received) {
|
|
663
|
+
const id = received.msg.messageId;
|
|
664
|
+
if (id && this.consumedIds.has(id))
|
|
665
|
+
return;
|
|
666
|
+
for (const [registrationId, waiter] of this.waiters) {
|
|
667
|
+
if (waiter.match(received.msg)) {
|
|
668
|
+
if (id)
|
|
669
|
+
this.consumedIds.add(id);
|
|
670
|
+
this.waiters.delete(registrationId);
|
|
671
|
+
waiter.resolve(received);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Only what no waiter claimed reaches the watchers, and only once each —
|
|
676
|
+
// a reconnect replay must not publish a second "lost" acknowledgement.
|
|
677
|
+
if (id) {
|
|
678
|
+
if (this.observedIds.has(id))
|
|
679
|
+
return;
|
|
680
|
+
this.observedIds.add(id);
|
|
681
|
+
}
|
|
682
|
+
for (const watcher of [...this.watchers.values()]) {
|
|
683
|
+
if (watcher.match(received.msg))
|
|
684
|
+
watcher.handle(received);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Turn one line of ntfy's ndjson stream into a HITL message.
|
|
689
|
+
*
|
|
690
|
+
* Also advances `lastEventTs`, including for keepalives — that is what lets a
|
|
691
|
+
* reconnect resume from where the stream stopped rather than from process
|
|
692
|
+
* start.
|
|
693
|
+
*/
|
|
694
|
+
parseEventLine(line) {
|
|
695
|
+
const trimmed = line.trim();
|
|
696
|
+
if (!trimmed)
|
|
697
|
+
return undefined;
|
|
698
|
+
let ntfyEvent;
|
|
699
|
+
try {
|
|
700
|
+
ntfyEvent = JSON.parse(trimmed);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
return undefined; // Not valid JSON, ignore
|
|
704
|
+
}
|
|
705
|
+
if (typeof ntfyEvent.time === 'number' && ntfyEvent.time > this.lastEventTs) {
|
|
706
|
+
this.lastEventTs = ntfyEvent.time;
|
|
707
|
+
}
|
|
708
|
+
if (typeof ntfyEvent.message !== 'string')
|
|
709
|
+
return undefined;
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(ntfyEvent.message);
|
|
712
|
+
let hitlMsg;
|
|
713
|
+
if (isEncryptedEnvelope(parsed)) {
|
|
714
|
+
if (!this.config.encryptionKey) {
|
|
715
|
+
console.error('Received encrypted message but no encryptionKey configured — skipping');
|
|
716
|
+
return undefined;
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
hitlMsg = JSON.parse(decrypt(ntfyEvent.message, this.config.encryptionKey));
|
|
720
|
+
}
|
|
721
|
+
catch (decryptErr) {
|
|
722
|
+
console.error('Failed to decrypt message:', decryptErr);
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
hitlMsg = parsed;
|
|
728
|
+
}
|
|
729
|
+
if (!hitlMsg?.type)
|
|
730
|
+
return undefined;
|
|
731
|
+
return { msg: hitlMsg, attachment: parseAttachment(ntfyEvent) };
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
return undefined; // Not a valid HITL message, ignore
|
|
735
|
+
}
|
|
736
|
+
}
|
|
101
737
|
/**
|
|
102
738
|
* Open a streaming connection to ntfy and invoke the callback for each parsed HITL message.
|
|
103
739
|
* Uses fetch streaming (works in Node 18+).
|
|
740
|
+
*
|
|
741
|
+
* `attachment` is the ntfy event's own attachment metadata, which only exists
|
|
742
|
+
* on the event envelope — never inside our message, since the URL is assigned
|
|
743
|
+
* by the PUT. Plan-review bodies over the inline threshold live there.
|
|
104
744
|
*/
|
|
105
745
|
async startSSEListener(url, signal, onMessage) {
|
|
106
746
|
const response = await fetch(url, {
|
|
@@ -108,7 +748,12 @@ export class NtfyTransport {
|
|
|
108
748
|
signal,
|
|
109
749
|
});
|
|
110
750
|
if (!response.ok) {
|
|
111
|
-
|
|
751
|
+
// The same classifier the publish path uses. This is the connection the
|
|
752
|
+
// human's answer actually travels on, so throwing away a diagnosable
|
|
753
|
+
// status here is what turns a misconfiguration into a silent hang (H2).
|
|
754
|
+
const body = await response.text().catch(() => '');
|
|
755
|
+
const verdict = classifyNtfyError(response.status, body);
|
|
756
|
+
throw new NtfySubscribeError(`ntfy subscription failed: ${verdict.message}`, response.status, verdict.retryable, verdict.code);
|
|
112
757
|
}
|
|
113
758
|
const reader = response.body?.getReader();
|
|
114
759
|
if (!reader)
|
|
@@ -124,45 +769,9 @@ export class NtfyTransport {
|
|
|
124
769
|
const lines = buffer.split('\n');
|
|
125
770
|
buffer = lines.pop() ?? '';
|
|
126
771
|
for (const line of lines) {
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
const ntfyEvent = JSON.parse(trimmed);
|
|
132
|
-
// ntfy wraps messages — the actual payload is in the 'message' field
|
|
133
|
-
if (ntfyEvent.message) {
|
|
134
|
-
try {
|
|
135
|
-
const parsed = JSON.parse(ntfyEvent.message);
|
|
136
|
-
let hitlMsg;
|
|
137
|
-
if (isEncryptedEnvelope(parsed)) {
|
|
138
|
-
if (!this.config.encryptionKey) {
|
|
139
|
-
console.error('Received encrypted message but no encryptionKey configured — skipping');
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
try {
|
|
143
|
-
const decrypted = decrypt(ntfyEvent.message, this.config.encryptionKey);
|
|
144
|
-
hitlMsg = JSON.parse(decrypted);
|
|
145
|
-
}
|
|
146
|
-
catch (decryptErr) {
|
|
147
|
-
console.error('Failed to decrypt message:', decryptErr);
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
hitlMsg = parsed;
|
|
153
|
-
}
|
|
154
|
-
if (hitlMsg.type) {
|
|
155
|
-
onMessage(hitlMsg);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
// Not a valid HITL message, ignore
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
// Not valid JSON, ignore
|
|
165
|
-
}
|
|
772
|
+
const received = this.parseEventLine(line);
|
|
773
|
+
if (received)
|
|
774
|
+
onMessage(received.msg, received.attachment);
|
|
166
775
|
}
|
|
167
776
|
}
|
|
168
777
|
}
|
|
@@ -173,11 +782,79 @@ export class NtfyTransport {
|
|
|
173
782
|
}
|
|
174
783
|
}
|
|
175
784
|
/**
|
|
176
|
-
* Close any active subscriptions.
|
|
785
|
+
* Close any active subscriptions and fail every outstanding wait.
|
|
177
786
|
*/
|
|
178
787
|
close() {
|
|
179
|
-
this.
|
|
180
|
-
this.
|
|
788
|
+
this.subscriptionAbort?.abort();
|
|
789
|
+
this.subscriptionAbort = null;
|
|
790
|
+
this.subscriptionRefs = 0;
|
|
791
|
+
this.watchers.clear();
|
|
792
|
+
this.failAllWaiters(new Error('Transport closed'));
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Settle every outstanding wait with the same failure.
|
|
796
|
+
*
|
|
797
|
+
* Each waiter's own `finish` removes it from the registry, so the snapshot is
|
|
798
|
+
* taken first — rejecting while iterating would mutate the map underneath.
|
|
799
|
+
*/
|
|
800
|
+
failAllWaiters(err) {
|
|
801
|
+
const waiters = [...this.waiters.values()];
|
|
802
|
+
this.waiters.clear();
|
|
803
|
+
for (const waiter of waiters) {
|
|
804
|
+
waiter.reject(err);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Raised when a wait is cancelled by its caller's AbortSignal.
|
|
810
|
+
*
|
|
811
|
+
* `reason` carries the external signal's own `AbortSignal.reason` (also set as
|
|
812
|
+
* the standard `Error.cause`) so a caller further up — an MCP tool handler
|
|
813
|
+
* deciding what to tell the agent — can distinguish a host-issued Stop from a
|
|
814
|
+
* host-side timeout instead of seeing only "it was cancelled".
|
|
815
|
+
*/
|
|
816
|
+
export class AbortedWaitError extends Error {
|
|
817
|
+
key;
|
|
818
|
+
reason;
|
|
819
|
+
constructor(key, reason) {
|
|
820
|
+
super(`Wait for ${key} was cancelled`, reason !== undefined ? { cause: reason } : undefined);
|
|
821
|
+
this.key = key;
|
|
822
|
+
this.name = 'AbortedWaitError';
|
|
823
|
+
this.reason = reason;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/** Full jitter, so concurrent servers behind one NAT do not retry in lockstep. */
|
|
827
|
+
function nextDelay(baseMs, maxMs) {
|
|
828
|
+
return Math.floor(Math.random() * Math.min(baseMs, maxMs));
|
|
829
|
+
}
|
|
830
|
+
function parseRetryAfter(header) {
|
|
831
|
+
if (!header)
|
|
832
|
+
return undefined;
|
|
833
|
+
const seconds = Number(header);
|
|
834
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
835
|
+
return seconds * 1000;
|
|
836
|
+
const date = Date.parse(header);
|
|
837
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
838
|
+
}
|
|
839
|
+
function sleep(ms, signal) {
|
|
840
|
+
if (ms <= 0)
|
|
841
|
+
return Promise.resolve();
|
|
842
|
+
return new Promise((resolve) => {
|
|
843
|
+
const timer = setTimeout(done, ms);
|
|
844
|
+
function done() {
|
|
845
|
+
clearTimeout(timer);
|
|
846
|
+
signal?.removeEventListener('abort', done);
|
|
847
|
+
resolve();
|
|
848
|
+
}
|
|
849
|
+
signal?.addEventListener('abort', done);
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
async function safeText(response) {
|
|
853
|
+
try {
|
|
854
|
+
return await response.text();
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
return '';
|
|
181
858
|
}
|
|
182
859
|
}
|
|
183
860
|
//# sourceMappingURL=ntfy-transport.js.map
|