@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.
Files changed (60) hide show
  1. package/dist/cli.d.ts +31 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +133 -5
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +2 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/git-context.d.ts +53 -2
  9. package/dist/git-context.d.ts.map +1 -1
  10. package/dist/git-context.js +118 -18
  11. package/dist/git-context.js.map +1 -1
  12. package/dist/host-settings.d.ts +68 -0
  13. package/dist/host-settings.d.ts.map +1 -0
  14. package/dist/host-settings.js +136 -0
  15. package/dist/host-settings.js.map +1 -0
  16. package/dist/identity.d.ts +20 -0
  17. package/dist/identity.d.ts.map +1 -0
  18. package/dist/identity.js +35 -0
  19. package/dist/identity.js.map +1 -0
  20. package/dist/mcp-server.d.ts +65 -1
  21. package/dist/mcp-server.d.ts.map +1 -1
  22. package/dist/mcp-server.js +446 -65
  23. package/dist/mcp-server.js.map +1 -1
  24. package/dist/ntfy-transport.d.ts +293 -13
  25. package/dist/ntfy-transport.d.ts.map +1 -1
  26. package/dist/ntfy-transport.js +765 -88
  27. package/dist/ntfy-transport.js.map +1 -1
  28. package/dist/payload.d.ts +80 -0
  29. package/dist/payload.d.ts.map +1 -0
  30. package/dist/payload.js +135 -0
  31. package/dist/payload.js.map +1 -0
  32. package/dist/plan-diff.d.ts +29 -0
  33. package/dist/plan-diff.d.ts.map +1 -0
  34. package/dist/plan-diff.js +138 -0
  35. package/dist/plan-diff.js.map +1 -0
  36. package/dist/plan-file.d.ts +24 -0
  37. package/dist/plan-file.d.ts.map +1 -0
  38. package/dist/plan-file.js +98 -0
  39. package/dist/plan-file.js.map +1 -0
  40. package/dist/plan-review.d.ts +54 -0
  41. package/dist/plan-review.d.ts.map +1 -0
  42. package/dist/plan-review.js +110 -0
  43. package/dist/plan-review.js.map +1 -0
  44. package/dist/setup.d.ts +22 -5
  45. package/dist/setup.d.ts.map +1 -1
  46. package/dist/setup.js +52 -15
  47. package/dist/setup.js.map +1 -1
  48. package/dist/snapshot-store.d.ts +104 -0
  49. package/dist/snapshot-store.d.ts.map +1 -0
  50. package/dist/snapshot-store.js +209 -0
  51. package/dist/snapshot-store.js.map +1 -0
  52. package/dist/types.d.ts +144 -1
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/types.js +2 -0
  55. package/dist/types.js.map +1 -1
  56. package/dist/version.d.ts +13 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +15 -0
  59. package/dist/version.js.map +1 -0
  60. package/package.json +64 -61
@@ -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
- * - Publishes messages (HTTP POST)
7
- * - Subscribes for answer messages (SSE stream)
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
- abortController = null;
12
- constructor(config) {
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 any HITL message to the ntfy topic.
22
- * If an encryption key is configured, the message payload is encrypted.
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
- const response = await fetch(this.topicUrl, {
43
- method: 'POST',
44
- headers: { 'Content-Type': 'application/json' },
45
- body,
46
- });
47
- if (!response.ok) {
48
- throw new Error(`Failed to publish message: ${response.status} ${response.statusText}`);
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
- * Subscribe and wait for an answer to a specific question.
61
- * Opens an SSE connection and filters for answer messages matching questionId.
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
- * @param questionId - The messageId of the question to wait for
64
- * @param timeout - Timeout in ms (0 = no timeout)
65
- * @returns The answer message
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
- async waitForAnswer(questionId, timeout) {
506
+ waitFor(key, match, signal) {
68
507
  return new Promise((resolve, reject) => {
69
- this.abortController = new AbortController();
70
- const { signal } = this.abortController;
71
- let timer;
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 cleanup = () => {
79
- if (timer)
80
- clearTimeout(timer);
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
- // Use ntfy's JSON stream endpoint — since=<now_unix> to only get future messages
83
- const sinceTs = Math.floor(Date.now() / 1000);
84
- const sseUrl = `${this.topicUrl}/json?since=${sinceTs}`;
85
- this.startSSEListener(sseUrl, signal, (msg) => {
86
- if (msg.type === 'answer' && msg.questionId === questionId) {
87
- cleanup();
88
- resolve(msg);
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
- signal.addEventListener('abort', () => {
97
- cleanup();
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
- throw new Error(`ntfy subscription failed: ${response.status} ${response.statusText}`);
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 trimmed = line.trim();
128
- if (!trimmed)
129
- continue;
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.abortController?.abort();
180
- this.abortController = null;
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