@achieveai/hitl-mcp-server 2.9.1 → 2.11.0

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