@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
@@ -4,25 +4,61 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
6
  import { fileURLToPath } from 'url';
7
+ import { realpathSync } from 'fs';
7
8
  import path from 'path';
8
- import { NtfyTransport } from './ntfy-transport.js';
9
+ import { PROTOCOL_VERSION } from './types.js';
10
+ import { NtfyTransport, AttachmentExpiredError, AbortedWaitError } from './ntfy-transport.js';
9
11
  import { loadConfig } from './config.js';
10
12
  import { detectRepoContext } from './git-context.js';
13
+ import { resolveSenderIdentity } from './identity.js';
11
14
  import { performSetup, ensureClientRunning } from './setup.js';
15
+ import { SERVER_VERSION } from './version.js';
16
+ import { readPlanFile, PlanFileError } from './plan-file.js';
17
+ import { resolvePlanIdentity, prepareRevision } from './snapshot-store.js';
18
+ import { resolveBaseline, buildPlanDiff } from './plan-diff.js';
19
+ import { encodePayload, decodePayload, PayloadDecodeError } from './payload.js';
20
+ import { parseVerdict, normalizeResponseBody, ReviewResponseError } from './plan-review.js';
12
21
  const TOOL_NAME = 'AskUserQuestion';
13
22
  const NOTIFY_TOOL_NAME = 'Notify';
14
23
  const SETUP_TOOL_NAME = 'setup';
24
+ const REVIEW_TOOL_NAME = 'ReviewPlan';
15
25
  const SERVER_NAME = 'hitl-mcp-server';
16
- const SERVER_VERSION = '2.9.6';
26
+ /** Interval between progress notifications that keep a blocked MCP call alive. */
27
+ const HEARTBEAT_INTERVAL_MS = 15_000;
28
+ /**
29
+ * How long to keep listening for a second device's submission after a review
30
+ * has already been answered, so the loser is told rather than left hanging.
31
+ */
32
+ const LATE_RESPONSE_WINDOW_MS = 45_000;
17
33
  /** Directory where the compiled server JS lives (used for relative binary paths). */
18
34
  const SERVER_DIR = path.dirname(fileURLToPath(import.meta.url));
19
- class HumanInTheLoopServer {
35
+ /** Best-effort message for an unknown thrown value, for ack reasons and logs. */
36
+ function describeError(err) {
37
+ return err instanceof Error ? err.message : String(err);
38
+ }
39
+ /**
40
+ * The honest message for a genuine abort of `waitFor`/`waitForAnswer`: a real,
41
+ * recoverable event, not the same thing as ntfy being unreachable.
42
+ *
43
+ * A Stop and a host-side timeout are the two things that actually cancel the
44
+ * wait — this says so instead of leaving the agent staring at "was cancelled".
45
+ * `CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=0` is offered as something to try, not
46
+ * as a stated cause: auto-background only ever detaches an already-running
47
+ * call from the foreground, it does not itself abort the request.
48
+ */
49
+ const ABORT_REMEDIATION_MESSAGE = 'Wait cancelled before response; may be Stop or host timeout; ' +
50
+ 'set CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS=0 in global settings and restart.';
51
+ export class HumanInTheLoopServer {
20
52
  server;
21
53
  transport;
22
- constructor() {
23
- const config = loadConfig();
54
+ config;
55
+ /** reviewIds still waiting on a human, so a graceful exit can release them (D-3). */
56
+ outstandingReviews = new Set();
57
+ /** `config` is injectable so a test can construct a server without a real ~/.hitl. */
58
+ constructor(config = loadConfig()) {
59
+ this.config = config;
24
60
  this.server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
25
- this.transport = new NtfyTransport(config);
61
+ this.transport = new NtfyTransport(this.config);
26
62
  this.setupHandlers();
27
63
  }
28
64
  setupHandlers() {
@@ -169,12 +205,6 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
169
205
  description: 'Whether to show an "Additional Context" text field for supplementary information',
170
206
  default: true,
171
207
  },
172
- timeout: {
173
- type: 'number',
174
- description: 'Timeout in milliseconds (default: 3600000 = 1 hour)',
175
- minimum: 1000,
176
- maximum: 86400000,
177
- },
178
208
  },
179
209
  required: ['context'],
180
210
  },
@@ -216,6 +246,43 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
216
246
  required: ['title', 'body'],
217
247
  },
218
248
  },
249
+ {
250
+ name: REVIEW_TOOL_NAME,
251
+ description: `Get line-anchored human review of an implementation plan you have written to a markdown file.
252
+
253
+ The plan opens as a two-pane review window on every device the user has subscribed — phone, laptop, desktop — so they can review it wherever they are. They select line ranges, attach comments to them, and return a verdict. This call blocks until they submit.
254
+
255
+ Use this instead of pasting a plan into AskUserQuestion whenever you want feedback on specific lines rather than a yes/no. Typical moments: after drafting an implementation plan, a migration sequence, an architecture proposal, or a refactor outline.
256
+
257
+ IMPORTANT — do not modify the file while this call is blocked. The returned snapshotHash identifies the exact content the human reviewed; if you rewrite the file mid-review, their approval applies to text that no longer exists.
258
+
259
+ Revisions: call it again with the same file after making the requested changes. The user sees a diff against what they reviewed last time rather than the whole plan again, and the revision number increments.
260
+
261
+ Returns JSON: { success, timestamp, respondedFrom, verdict, overallFeedback, inlineComments[], revision, isNewPlan, snapshotHash }.
262
+ - verdict is one of: approved | changes_requested | rejected | skipped | cancelled
263
+ - inlineComments are { path, startLine, endLine, side, comment }, stably sorted, with line numbers in the source-line space of the file you passed
264
+ - changes_requested and rejected always carry either overallFeedback or at least one inline comment
265
+
266
+ Blocking past 60 seconds requires the calling MCP host to opt into resetTimeoutOnProgress; this server emits progress heartbeats but cannot enforce the host's timeout.`,
267
+ inputSchema: {
268
+ type: 'object',
269
+ properties: {
270
+ filePath: {
271
+ type: 'string',
272
+ description: 'Absolute or cwd-relative path to the markdown plan file (.md or .markdown, up to 1 MB).',
273
+ },
274
+ context: {
275
+ type: 'string',
276
+ description: 'Brief description of what project and work you are doing. This helps the human understand the situation across devices.',
277
+ },
278
+ summary: {
279
+ type: 'string',
280
+ description: 'Optional short prose framing shown above the document — what changed since last time, or what you most want feedback on.',
281
+ },
282
+ },
283
+ required: ['filePath', 'context'],
284
+ },
285
+ },
219
286
  ],
220
287
  }));
221
288
  this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
@@ -238,7 +305,7 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
238
305
  throw new McpError(ErrorCode.InvalidParams, 'Missing required parameters: title and body');
239
306
  }
240
307
  try {
241
- ensureClientRunning(SERVER_DIR);
308
+ this.requireClient();
242
309
  const notification = {
243
310
  type: 'notification',
244
311
  messageId: uuidv4(),
@@ -248,6 +315,7 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
248
315
  context: args.context || undefined,
249
316
  };
250
317
  await this.transport.publish(notification);
318
+ await this.publishSenderIdentityFor(notification.messageId, 'notification');
251
319
  return {
252
320
  content: [{ type: 'text', text: JSON.stringify({ success: true, messageId: notification.messageId }) }],
253
321
  };
@@ -256,6 +324,9 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
256
324
  throw new McpError(ErrorCode.InternalError, `Failed to send notification: ${error instanceof Error ? error.message : 'Unknown error'}`);
257
325
  }
258
326
  }
327
+ if (request.params.name === REVIEW_TOOL_NAME) {
328
+ return await this.handleReviewPlan(request.params.arguments, extra);
329
+ }
259
330
  if (request.params.name !== TOOL_NAME) {
260
331
  throw new McpError(ErrorCode.MethodNotFound, `Tool not found: ${request.params.name}`);
261
332
  }
@@ -269,8 +340,9 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
269
340
  throw new McpError(ErrorCode.InvalidParams, 'Provide either question+options for a single question, or a questions array for batch mode');
270
341
  }
271
342
  try {
272
- // Auto-launch client if not running
273
- ensureClientRunning(SERVER_DIR);
343
+ // Auto-launch client if not running. Must precede publish: with no
344
+ // timeout, publishing to a topic nobody reads blocks forever (A-10).
345
+ this.requireClient();
274
346
  const repo = detectRepoContext();
275
347
  const mapOption = (opt) => ({
276
348
  label: opt.label || opt.value || '',
@@ -306,44 +378,28 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
306
378
  : args.options.map(mapOption),
307
379
  allowMultiple: hasBatchQuestions ? false : args.allowMultiple !== false,
308
380
  allowOther: hasBatchQuestions ? true : args.allowOther !== false,
309
- timeout: args.timeout || 3600000,
310
381
  questions: batchQuestions,
311
382
  };
312
383
  console.error(`Publishing question ${questionMsg.messageId} to ntfy...`);
313
384
  await this.transport.publish(questionMsg);
314
385
  console.error('Question published. Waiting for answer...');
315
- // Send periodic progress notifications to prevent MCP client timeout.
316
- // Each progress notification resets the client's countdown timer.
317
- const HEARTBEAT_INTERVAL_MS = 15_000;
318
- const progressToken = extra?._meta?.progressToken;
319
- let heartbeatTimer;
320
- let progressCount = 0;
321
- if (progressToken !== undefined) {
322
- heartbeatTimer = setInterval(async () => {
323
- progressCount++;
324
- try {
325
- await extra.sendNotification({
326
- method: 'notifications/progress',
327
- params: {
328
- progressToken,
329
- progress: progressCount,
330
- total: 0,
331
- message: 'Waiting for human response...',
332
- },
333
- });
334
- }
335
- catch {
336
- // Client may have disconnected; ignore
337
- }
338
- }, HEARTBEAT_INTERVAL_MS);
339
- }
386
+ await this.publishSenderIdentityFor(questionMsg.messageId, 'question');
387
+ this.transport.pending.record({
388
+ kind: 'question',
389
+ id: questionMsg.messageId,
390
+ createdAt: Date.now(),
391
+ });
340
392
  let answer;
393
+ const stopHeartbeat = this.startHeartbeat(extra);
341
394
  try {
342
- answer = await this.transport.waitForAnswer(questionMsg.messageId, questionMsg.timeout);
395
+ answer = await this.transport.waitForAnswer(questionMsg.messageId, extra?.signal);
343
396
  }
344
397
  finally {
345
- if (heartbeatTimer)
346
- clearInterval(heartbeatTimer);
398
+ // One finally for both: a host cancellation must release the SSE
399
+ // connection and the timer together, or every stop/retry cycle leaks
400
+ // one of each for the life of the process (D-9).
401
+ stopHeartbeat();
402
+ this.transport.pending.clear(questionMsg.messageId);
347
403
  }
348
404
  console.error(`Answer received from ${answer.respondedFrom}`);
349
405
  // Strip (RECOMMENDED) markers from values
@@ -400,40 +456,365 @@ IMPORTANT: When in doubt, ASK. Getting human input ensures accuracy and saves ti
400
456
  }
401
457
  catch (error) {
402
458
  console.error('Dialog error:', error);
403
- if (error instanceof Error && error.message === 'Dialog timeout') {
404
- return {
405
- content: [
406
- {
407
- type: 'text',
408
- text: JSON.stringify({
409
- success: false,
410
- error: 'timeout',
411
- message: 'The user did not respond within the timeout period',
412
- }, null, 2),
413
- },
414
- ],
415
- };
459
+ if (error instanceof AbortedWaitError) {
460
+ throw new McpError(ErrorCode.InternalError, ABORT_REMEDIATION_MESSAGE);
416
461
  }
417
462
  throw new McpError(ErrorCode.InternalError, `Failed to get human response: ${error instanceof Error ? error.message : 'Unknown error'}`);
418
463
  }
419
464
  });
420
465
  }
466
+ /**
467
+ * ReviewPlan: snapshot a markdown plan, publish it for line-anchored review,
468
+ * and block until a human submits a verdict.
469
+ */
470
+ async handleReviewPlan(args, extra) {
471
+ if (typeof args?.filePath !== 'string' || args.filePath.trim() === '') {
472
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: filePath');
473
+ }
474
+ if (typeof args?.context !== 'string' || args.context.trim() === '') {
475
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required parameter: context');
476
+ }
477
+ // Validate and read before anything else: a rejected path must never reach
478
+ // the snapshot store, ntfy, or the human's screen.
479
+ let plan;
480
+ try {
481
+ plan = readPlanFile(args.filePath);
482
+ }
483
+ catch (err) {
484
+ if (err instanceof PlanFileError) {
485
+ throw new McpError(ErrorCode.InvalidParams, err.message);
486
+ }
487
+ throw err;
488
+ }
489
+ this.requireClient();
490
+ const identity = resolvePlanIdentity(plan.resolvedPath);
491
+ let recorded;
492
+ try {
493
+ recorded = prepareRevision(identity, plan.content);
494
+ }
495
+ catch (err) {
496
+ // The store retries what is transient; anything reaching here is a real
497
+ // filesystem problem, and a raw EPERM stack is not something an agent
498
+ // can act on.
499
+ throw new McpError(ErrorCode.InternalError, `Could not record a snapshot of ${identity.displayPath}: ${describeError(err)}. ` +
500
+ `The plan review needs to write under ${path.dirname(identity.dir)}.`);
501
+ }
502
+ const snapshotHash = `sha256:${recorded.digest}`;
503
+ const diff = buildPlanDiff(identity.displayPath, plan.content, resolveBaseline(identity, recorded));
504
+ // Reuse the reviewId of an identical review this or a previous process was
505
+ // still waiting on. A window may still be open for it on the human's
506
+ // device, and its response then resolves this call instead of asking
507
+ // again (D-8).
508
+ const resumable = this.transport.pending.findResumableReview(identity.planId, snapshotHash);
509
+ const reviewId = resumable?.id ?? uuidv4();
510
+ const body = { content: plan.content, diff };
511
+ const encoded = encodePayload(body, this.config.encryptionKey);
512
+ // Resolved from the plan's own directory, matching the `repo` field just
513
+ // above — not process.cwd(), and not named `identity` since that local is
514
+ // already bound to `resolvePlanIdentity(...)` a few lines up.
515
+ const senderIdentity = this.config.identityEnabled !== false
516
+ ? resolveSenderIdentity(path.dirname(plan.resolvedPath), this.config.deviceName)
517
+ : undefined;
518
+ const reviewMsg = {
519
+ type: 'plan_review',
520
+ messageId: reviewId,
521
+ timestamp: Date.now(),
522
+ protocolVersion: PROTOCOL_VERSION,
523
+ repo: detectRepoContext(path.dirname(plan.resolvedPath)),
524
+ context: args.context,
525
+ summary: typeof args.summary === 'string' ? args.summary : '',
526
+ displayPath: identity.displayPath,
527
+ planId: identity.planId,
528
+ revision: recorded.revision,
529
+ isNewPlan: recorded.isNewPlan,
530
+ sender: senderIdentity,
531
+ snapshotHash,
532
+ body: encoded.ref,
533
+ };
534
+ this.transport.pending.record({
535
+ kind: 'plan_review',
536
+ id: reviewId,
537
+ planId: identity.planId,
538
+ snapshotHash,
539
+ createdAt: Date.now(),
540
+ });
541
+ this.outstandingReviews.add(reviewId);
542
+ console.error(`Publishing plan_review ${reviewId} (revision ${recorded.revision}, ` +
543
+ `${encoded.ref.kind} payload, ${encoded.ref.contentLength} bytes) to ntfy...`);
544
+ const stopHeartbeat = this.startHeartbeat(extra);
545
+ // Watch for a second device submitting after the first one already won.
546
+ // Registered before the wait so there is no window in which a late
547
+ // response goes unnoticed; `watch` only sees what no waiter consumed, so
548
+ // the winning response never reaches it (D-5).
549
+ const stopWatchingLateResponses = this.transport.watch(`late_response:${reviewId}`, (msg) => msg.type === 'plan_review_response' && msg.reviewId === reviewId, (received) => {
550
+ const late = received.msg;
551
+ console.error(`Review ${reviewId} already resolved; telling ${late.respondedFrom} it was lost.`);
552
+ void this.publishAck(reviewId, late.messageId, 'lost', 'This review had already been submitted from another device.');
553
+ });
554
+ try {
555
+ await this.transport.publishPlan(reviewMsg, encoded.ref.kind === 'attachment' ? encoded.cipher : undefined);
556
+ // The revision is out the door, so it may now become the baseline the
557
+ // next review diffs against (M6). A failure here only leaves the next
558
+ // diff rebased on the older revision — worth saying out loud, not worth
559
+ // abandoning a review the human is already looking at.
560
+ try {
561
+ recorded.commit();
562
+ }
563
+ catch (err) {
564
+ console.error(`Published review ${reviewId} but could not update the snapshot pointer for ` +
565
+ `${identity.displayPath}: ${describeError(err)}. The next review will diff against ` +
566
+ `revision ${recorded.previous?.revision ?? 0}.`);
567
+ }
568
+ const { msg: response, attachment } = await this.transport.waitFor(`plan_review_response:${reviewId}`, (msg) => msg.type === 'plan_review_response' && msg.reviewId === reviewId, extra?.signal);
569
+ const result = await this.consumeReviewResponse(reviewMsg, response, attachment);
570
+ // Stay attached briefly so a submit that lost the race still gets told,
571
+ // rather than the losing client sitting on a 30 s ack timeout.
572
+ this.releaseLater(stopWatchingLateResponses);
573
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
574
+ }
575
+ catch (error) {
576
+ stopWatchingLateResponses();
577
+ if (error instanceof McpError)
578
+ throw error;
579
+ console.error('ReviewPlan error:', error);
580
+ if (error instanceof AbortedWaitError) {
581
+ throw new McpError(ErrorCode.InternalError, ABORT_REMEDIATION_MESSAGE);
582
+ }
583
+ throw new McpError(ErrorCode.InternalError, `Failed to get plan review: ${error instanceof Error ? error.message : 'Unknown error'}`);
584
+ }
585
+ finally {
586
+ // Same finally for the timer and the wait registration: a host
587
+ // cancellation must release both, not leak one per stop/retry cycle (D-9).
588
+ stopHeartbeat();
589
+ this.outstandingReviews.delete(reviewId);
590
+ this.transport.pending.clear(reviewId);
591
+ }
592
+ }
593
+ /**
594
+ * Drop a subscription hold after the late-response window.
595
+ *
596
+ * The timer is unref'd so it can never be the reason the process stays alive.
597
+ */
598
+ releaseLater(release) {
599
+ setTimeout(release, LATE_RESPONSE_WINDOW_MS).unref?.();
600
+ }
601
+ /**
602
+ * Download, decode and validate a submitted review, then acknowledge it.
603
+ *
604
+ * The ack is what lets the client distinguish "the server read this" from "I
605
+ * clicked submit": the response attachment expires after 3 h while the
606
+ * message itself survives 12 h, so a submit can be accepted at click time and
607
+ * still never arrive (C-12).
608
+ */
609
+ async consumeReviewResponse(reviewMsg, response, attachment) {
610
+ let verdict;
611
+ let body;
612
+ try {
613
+ // Inside the try so a client that sends nonsense is acknowledged as
614
+ // lost rather than leaving the human's window claiming success.
615
+ verdict = parseVerdict(response.verdict);
616
+ // The human must have reviewed the content we published. A mismatch is a
617
+ // stale window or a broken client, and accepting it would approve this
618
+ // revision on the strength of a review of a different one (A-8/M5).
619
+ if (response.snapshotHash && response.snapshotHash !== reviewMsg.snapshotHash) {
620
+ throw new ReviewResponseError(`Response is for snapshot ${response.snapshotHash}, but this review published ` +
621
+ `${reviewMsg.snapshotHash}.`);
622
+ }
623
+ const cipher = response.body?.kind === 'attachment'
624
+ ? await this.downloadResponseBody(attachment)
625
+ : response.body?.data ?? '';
626
+ const decoded = decodePayload(cipher, this.config.encryptionKey, response.body?.contentHash ?? '');
627
+ body = normalizeResponseBody(verdict, decoded, reviewMsg.displayPath);
628
+ }
629
+ catch (err) {
630
+ await this.publishAck(reviewMsg.messageId, response.messageId, 'lost', describeError(err));
631
+ if (err instanceof AttachmentExpiredError) {
632
+ throw new McpError(ErrorCode.InternalError, `The review was submitted but its payload had already expired on ntfy (attachments live 3 h). ` +
633
+ `Ask for the review again.`);
634
+ }
635
+ if (err instanceof PayloadDecodeError || err instanceof ReviewResponseError) {
636
+ throw new McpError(ErrorCode.InternalError, `Unusable review response: ${err.message}`);
637
+ }
638
+ throw err;
639
+ }
640
+ await this.publishAck(reviewMsg.messageId, response.messageId, 'received');
641
+ return {
642
+ success: true,
643
+ timestamp: response.timestamp,
644
+ respondedFrom: response.respondedFrom,
645
+ verdict,
646
+ overallFeedback: body.overallFeedback,
647
+ inlineComments: body.inlineComments,
648
+ revision: reviewMsg.revision,
649
+ isNewPlan: reviewMsg.isNewPlan,
650
+ // Checked against what we published above, so this is the content the
651
+ // human actually reviewed rather than whatever the client claimed (A-8).
652
+ snapshotHash: reviewMsg.snapshotHash,
653
+ };
654
+ }
655
+ async downloadResponseBody(attachment) {
656
+ if (!attachment) {
657
+ throw new McpError(ErrorCode.InternalError, 'Review response claims an attachment payload but the message carried no attachment URL.');
658
+ }
659
+ return await this.transport.downloadAttachment(attachment);
660
+ }
661
+ /**
662
+ * Publish the sender's own identity as a companion to a question or
663
+ * notification just published, unless the operator opted out.
664
+ *
665
+ * Shared by AskUserQuestion and Notify — both resolve identity from
666
+ * `process.cwd()` (ReviewPlan resolves from the plan's own directory
667
+ * instead, so it is not part of this shared path). Decoration only: never
668
+ * lets a publish failure escape past the tool call it decorates, matching
669
+ * `publishAck`'s log-and-continue pattern below.
670
+ */
671
+ async publishSenderIdentityFor(forMessageId, forType) {
672
+ if (this.config.identityEnabled === false)
673
+ return;
674
+ try {
675
+ const senderIdentity = resolveSenderIdentity(process.cwd(), this.config.deviceName);
676
+ await this.transport.publishSenderIdentity({
677
+ type: 'sender_identity',
678
+ forMessageId,
679
+ forType,
680
+ sender: senderIdentity,
681
+ });
682
+ }
683
+ catch (err) {
684
+ console.error(`Could not publish sender identity for ${forMessageId}: ${err}`);
685
+ }
686
+ }
687
+ /** Tell the client whether its submission actually landed. */
688
+ async publishAck(reviewId, responseId, status, reason) {
689
+ const ack = {
690
+ type: 'plan_review_ack',
691
+ messageId: uuidv4(),
692
+ timestamp: Date.now(),
693
+ protocolVersion: PROTOCOL_VERSION,
694
+ reviewId,
695
+ responseId,
696
+ status,
697
+ reason,
698
+ };
699
+ try {
700
+ await this.transport.publishPlan(ack);
701
+ }
702
+ catch (err) {
703
+ console.error(`Could not acknowledge review ${reviewId}: ${err}`);
704
+ }
705
+ }
706
+ /**
707
+ * Fail before publishing when no client can be found or launched.
708
+ *
709
+ * Without a timeout to bound it, publishing to a topic nobody is subscribed
710
+ * to is a permanent silent hang — the agent blocks and the human never sees a
711
+ * window to explain why (A-10).
712
+ */
713
+ requireClient() {
714
+ const result = ensureClientRunning(SERVER_DIR);
715
+ if (!result.ok) {
716
+ throw new McpError(ErrorCode.InternalError, `No HITL client available, so nobody would see this. ${result.reason ?? ''}`.trim());
717
+ }
718
+ }
719
+ /**
720
+ * Start emitting progress notifications, returning the stop function.
721
+ *
722
+ * Whether these actually keep the call alive is the calling host's decision:
723
+ * the MCP SDK's DEFAULT_REQUEST_TIMEOUT_MSEC is 60 s and
724
+ * `resetTimeoutOnProgress` defaults to false, so a host that does not opt in
725
+ * will time the call out regardless of what we send (D-2).
726
+ */
727
+ startHeartbeat(extra) {
728
+ const progressToken = extra?._meta?.progressToken;
729
+ if (progressToken === undefined)
730
+ return () => { };
731
+ let progressCount = 0;
732
+ const timer = setInterval(async () => {
733
+ progressCount++;
734
+ try {
735
+ await extra.sendNotification({
736
+ method: 'notifications/progress',
737
+ params: {
738
+ progressToken,
739
+ progress: progressCount,
740
+ total: 0,
741
+ message: 'Waiting for human response...',
742
+ },
743
+ });
744
+ }
745
+ catch {
746
+ // Client may have disconnected; ignore
747
+ }
748
+ }, HEARTBEAT_INTERVAL_MS);
749
+ return () => clearInterval(timer);
750
+ }
421
751
  async run() {
422
752
  const stdioTransport = new StdioServerTransport();
423
753
  await this.server.connect(stdioTransport);
424
754
  console.error(`${SERVER_NAME} v${SERVER_VERSION} running on stdio (ntfy-backed)`);
425
- const shutdown = () => {
755
+ let shuttingDown = false;
756
+ const shutdown = async () => {
757
+ if (shuttingDown)
758
+ return;
759
+ shuttingDown = true;
426
760
  console.error('Shutting down...');
761
+ // Tell open review windows their agent is gone before dropping the
762
+ // connection, so they show "agent exited" and keep the typed comments
763
+ // rather than waiting on a process that no longer exists (D-3).
764
+ // A hard kill (SIGKILL/OOM) cannot be caught here by design.
765
+ await this.cancelOutstandingReviews('agent_exited');
427
766
  this.transport.close();
428
767
  process.exit(0);
429
768
  };
430
- process.on('SIGINT', shutdown);
431
- process.on('SIGTERM', shutdown);
769
+ process.on('SIGINT', () => void shutdown());
770
+ process.on('SIGTERM', () => void shutdown());
432
771
  }
772
+ /** Publish cancel_review for every review still waiting on a human. */
773
+ async cancelOutstandingReviews(reason) {
774
+ for (const reviewId of this.outstandingReviews) {
775
+ const cancel = {
776
+ type: 'cancel_review',
777
+ messageId: uuidv4(),
778
+ timestamp: Date.now(),
779
+ protocolVersion: PROTOCOL_VERSION,
780
+ reviewId,
781
+ reason,
782
+ };
783
+ try {
784
+ await this.transport.publishPlan(cancel);
785
+ }
786
+ catch (err) {
787
+ console.error(`Could not cancel review ${reviewId}: ${err}`);
788
+ }
789
+ }
790
+ this.outstandingReviews.clear();
791
+ }
792
+ }
793
+ /**
794
+ * True when this file is what node was asked to run, rather than something
795
+ * another module imported.
796
+ *
797
+ * Compared through realpath because npm installs the `bin` entry as a symlink:
798
+ * `process.argv[1]` is then the link in `node_modules/.bin`, not this file. If
799
+ * the comparison cannot be made at all we boot — failing to start a server
800
+ * someone asked for is far worse than starting one nobody wanted.
801
+ */
802
+ function isDirectlyExecuted() {
803
+ const entry = process.argv[1];
804
+ if (entry === undefined)
805
+ return false;
806
+ try {
807
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
808
+ }
809
+ catch {
810
+ return true;
811
+ }
812
+ }
813
+ if (isDirectlyExecuted()) {
814
+ const server = new HumanInTheLoopServer();
815
+ server.run().catch((error) => {
816
+ console.error('Server error:', error);
817
+ process.exit(1);
818
+ });
433
819
  }
434
- const server = new HumanInTheLoopServer();
435
- server.run().catch((error) => {
436
- console.error('Server error:', error);
437
- process.exit(1);
438
- });
439
820
  //# sourceMappingURL=mcp-server.js.map