immune-brain 3.2.2 → 3.4.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 (32) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +3 -3
  3. package/package.json +5 -3
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -73
  6. package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +1 -2
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +2 -2
  8. package/plugins/immune-brain/dist/BASELINE.md +1 -1
  9. package/plugins/immune-brain/dist/claude/mcp-server.mjs +317 -316
  10. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +1 -1
  11. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  12. package/plugins/immune-brain/dist/imm-loop.md +15 -10
  13. package/plugins/immune-brain/dist/imm-planner.md +22 -17
  14. package/plugins/immune-brain/hooks/hooks.json +0 -10
  15. package/plugins/immune-brain/runtime/assurance/coordinator.ts +0 -8
  16. package/plugins/immune-brain/runtime/claude/capability.ts +4 -10
  17. package/plugins/immune-brain/runtime/claude/interaction.ts +55 -22
  18. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +63 -73
  19. package/plugins/immune-brain/runtime/claude/mcp_server.ts +233 -55
  20. package/plugins/immune-brain/runtime/claude/review_host.ts +2 -138
  21. package/plugins/immune-brain/runtime/kernel/application.ts +0 -1
  22. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +1 -7
  23. package/plugins/immune-brain/runtime/kernel/canary_application.ts +0 -7
  24. package/plugins/immune-brain/runtime/kernel/completion.ts +1 -3
  25. package/plugins/immune-brain/runtime/kernel/reducer.ts +0 -31
  26. package/plugins/immune-brain/runtime/kernel/types.ts +0 -2
  27. package/plugins/immune-brain/runtime/kernel/validation.ts +1 -3
  28. package/plugins/immune-brain/runtime/plugin_version.ts +2 -0
  29. package/plugins/immune-brain/skills/BASELINE.md +1 -1
  30. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -0
  31. package/plugins/immune-brain/skills/imm-loop/SKILL.md +4 -0
  32. package/plugins/immune-brain/skills/imm-planner/SKILL.md +6 -0
@@ -1,12 +1,22 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { createInterface } from "node:readline";
2
3
  import { stdin, stdout } from "node:process";
3
4
  import type { Readable, Writable } from "node:stream";
4
5
  import { CORE_CONTRACT, HOST_ID, MIN_CLAUDE_CODE_VERSION, probeHost } from "./capability";
5
- import { isPrivilegedOperation, privilegedAnnotations, type NativeDecision } from "./interaction";
6
+ import { PLUGIN_VERSION } from "../plugin_version";
7
+ import {
8
+ isPrivilegedOperation,
9
+ NativeAuthorityError,
10
+ privilegedAnnotations,
11
+ type NativeConfirmationInput,
12
+ type NativeConfirmationPort,
13
+ } from "./interaction";
6
14
  import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
7
15
  import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
8
16
  import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
9
17
 
18
+ export const MCP_PROTOCOL_VERSION = "2025-06-18";
19
+
10
20
  export const TOOLS = [
11
21
  { name: "status", description: "Read the Kernel Assurance Projection for an exact task.", privileged: false },
12
22
  { name: "enroll", description: "Enroll a Git-tracked TaskIntent after native confirmation.", privileged: true },
@@ -15,7 +25,7 @@ export const TOOLS = [
15
25
  { name: "request_authorization", description: "Apply exact literal-user authorization.", privileged: true },
16
26
  { name: "approve_breaking_intent_revision", description: "Approve a breaking TaskIntent revision.", privileged: true },
17
27
  { name: "stop", description: "Stop the active task with literal-user authority.", privileged: true },
18
- { name: "repair_authority_state", description: "Repair a recoverable stale backend claim.", privileged: true },
28
+ { name: "repair_authority_state", description: "Repair a proven recoverable stale backend claim.", privileged: false },
19
29
  ] as const;
20
30
 
21
31
  export function listMcpTools() {
@@ -36,13 +46,17 @@ export function listMcpTools() {
36
46
  }));
37
47
  }
38
48
 
49
+ export function supportsElicitationProtocol(value: unknown): boolean {
50
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && value >= MCP_PROTOCOL_VERSION;
51
+ }
52
+
39
53
  export interface McpRuntimeOptions {
40
54
  cwd?: string;
41
55
  env?: Record<string, string | undefined>;
42
56
  ports?: AssuranceCoordinatorPorts;
43
57
  host?: ClaudeReviewHost;
44
58
  interactive?: boolean;
45
- decisions?: Map<string, NativeDecision>;
59
+ requestConfirmation?: NativeConfirmationPort;
46
60
  }
47
61
 
48
62
  export function createMcpRuntime(options: McpRuntimeOptions = {}) {
@@ -53,21 +67,26 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
53
67
  host,
54
68
  ports: options.ports,
55
69
  interactive: options.interactive,
56
- decisions: options.decisions,
70
+ requestConfirmation: options.requestConfirmation,
57
71
  });
58
72
  // Trusted Host evidence negotiated on this JSON-RPC connection. Absent
59
73
  // handshake evidence means unversioned and non-interactive: fail closed.
60
74
  let negotiatedVersion: string | undefined;
61
75
  let negotiatedInteractive = false;
76
+ const connectionId = randomUUID();
62
77
  return {
63
78
  runtime,
64
79
  host,
80
+ connectionId,
65
81
  listTools: listMcpTools,
66
- bindClientHandshake(identity: { version: string; interactive: boolean }) {
82
+ bindClientHandshake(identity: { version: string; interactive: boolean; protocolVersion?: string }) {
67
83
  negotiatedVersion = identity.version || undefined;
68
- negotiatedInteractive = identity.interactive;
84
+ negotiatedInteractive = identity.interactive && supportsElicitationProtocol(identity.protocolVersion);
69
85
  runtime.bindHostVersion(negotiatedVersion);
70
86
  },
87
+ bindNativeConfirmation(port: NativeConfirmationPort) {
88
+ runtime.bindNativeConfirmation(port);
89
+ },
71
90
  sessionInteractive: () => negotiatedInteractive,
72
91
  async callTool(name: string, args: Record<string, unknown>, meta: Partial<ToolMeta> = {}) {
73
92
  const taskId = String(args.task_id ?? "");
@@ -75,26 +94,24 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
75
94
  if ("native_decision" in args) throw new Error("native_decision cannot be supplied in tool arguments");
76
95
  // Read-only status must stay usable without trusted Host evidence, so it
77
96
  // is dispatched before the capability probe rejects unversioned hosts.
78
- if (name === "status") return runtime.status(taskId);
97
+ if (name === "status") return { plugin_version: PLUGIN_VERSION, ...(await runtime.status(taskId)) };
79
98
  // The environment version fallback is disabled on this connection:
80
99
  // only a version bound from the trusted initialize handshake may
81
100
  // reach authority-mutating tools.
82
- if (!negotiatedVersion) throw new Error("Claude Code version is unavailable");
83
- // Every authority-mutating tool requires the handshake to have
84
- // declared elicitation support: only read-only status works on
85
- // non-interactive sessions, so QA/review/completion cannot mutate
86
- // Kernel state without native interaction capability.
87
- if (!negotiatedInteractive) throw new Error("non-interactive host session cannot execute authority tools");
101
+ if (!negotiatedVersion) throw new NativeAuthorityError("unsupported_host", "Claude Code version is unavailable");
102
+ // Automatic stale-claim repair is deterministic and needs no native
103
+ // interaction; other mutations retain the Host capability requirement.
104
+ if (!negotiatedInteractive && name !== "repair_authority_state") {
105
+ throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
106
+ }
88
107
  const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
89
- if (!probe.ok) throw new Error(probe.reason);
108
+ if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
90
109
  const toolMeta: ToolMeta = {
91
- sessionId: meta.sessionId ?? "session",
110
+ sessionId: meta.sessionId ?? connectionId,
92
111
  toolCallId: meta.toolCallId ?? `call-${name}`,
93
112
  taskId,
94
- requiresUserInteraction: meta.requiresUserInteraction ?? isPrivilegedOperation(name),
95
- permissionMode: meta.permissionMode ?? probe.permissionMode,
96
- interactive: meta.interactive ?? options.interactive ?? false,
97
- decision: meta.decision,
113
+ interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
114
+ signal: meta.signal,
98
115
  };
99
116
  if (name === "enroll") return runtime.enroll(taskId, toolMeta);
100
117
  if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal);
@@ -108,7 +125,6 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
108
125
  throw new Error(`unknown tool ${name}`);
109
126
  },
110
127
  observe: (event: Parameters<ClaudeReviewHost["observe"]>[0]) => host.observe(event),
111
- sessionOfElicitation: (toolCallId: string) => host.sessionOfElicitation(toolCallId),
112
128
  shutdown: () => runtime.shutdown(),
113
129
  aborts: new Map<string | number, AbortController>(),
114
130
  };
@@ -120,25 +136,24 @@ interface JsonRpc {
120
136
  method?: string;
121
137
  params?: unknown;
122
138
  result?: unknown;
123
- error?: { code: number; message: string };
139
+ error?: { code: number; message: string; data?: Record<string, unknown> };
124
140
  }
125
141
 
126
- function hostCallIdentity(
127
- meta: Record<string, unknown> | undefined,
128
- resolveSession?: (toolCallId: string) => string | undefined,
129
- ): { sessionId: string; toolCallId: string } | undefined {
142
+ function hostCallIdentity(meta: Record<string, unknown> | undefined): { toolCallId: string } | undefined {
130
143
  if (!meta) return undefined;
131
- // Real Claude Code wire correlation metadata carries ONLY the namespaced
132
- // claudecode/toolUseId key; any other correlation key (tool_use_id,
133
- // toolUseId, toolCallId) is noncanonical and fails closed. The Host session
134
- // binding is always derived from the sole unconsumed ElicitationResult
135
- // record for that exact call; no record, or an ambiguous record, means no
136
- // native interaction: fail closed. A supplied session_id is never trusted
137
- // and can never bypass ambiguity resolution.
138
144
  const toolCallId = meta["claudecode/toolUseId"];
139
- if (typeof toolCallId !== "string" || !toolCallId) return undefined;
140
- const sessionId = resolveSession?.(toolCallId);
141
- return sessionId ? { sessionId, toolCallId } : undefined;
145
+ return typeof toolCallId === "string" && toolCallId ? { toolCallId } : undefined;
146
+ }
147
+
148
+ function rpcError(error: unknown): { code: number; message: string; data?: Record<string, unknown> } {
149
+ if (error instanceof NativeAuthorityError) {
150
+ return {
151
+ code: -32000,
152
+ message: error.message,
153
+ data: { reason_code: error.reasonCode, recovery_action: error.recoveryAction },
154
+ };
155
+ }
156
+ return { code: -32000, message: error instanceof Error ? error.message : String(error) };
142
157
  }
143
158
 
144
159
  function encodeMessage(message: JsonRpc): Buffer {
@@ -148,6 +163,7 @@ function encodeMessage(message: JsonRpc): Buffer {
148
163
  export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()): Promise<JsonRpc | null> {
149
164
  if (message.method === "initialize") {
150
165
  const params = (message.params ?? {}) as {
166
+ protocolVersion?: unknown;
151
167
  clientInfo?: { name?: unknown; version?: unknown };
152
168
  capabilities?: Record<string, unknown>;
153
169
  };
@@ -158,17 +174,20 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
158
174
  const elicitation = params.capabilities?.elicitation;
159
175
  mcp.bindClientHandshake({
160
176
  version: trustedClient && typeof params.clientInfo?.version === "string" ? params.clientInfo.version : "",
161
- // A client that does not declare elicitation support cannot surface
162
- // the native interactions privileged authority requires; only a
163
- // valid capability object counts, not any non-null value.
177
+ protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : undefined,
164
178
  interactive: trustedClient && elicitation !== null && typeof elicitation === "object" && !Array.isArray(elicitation),
165
179
  });
166
180
  return {
167
181
  jsonrpc: "2.0",
168
182
  id: message.id ?? null,
169
183
  result: {
170
- protocolVersion: "2024-11-05",
171
- serverInfo: { name: HOST_ID, version: MIN_CLAUDE_CODE_VERSION, contract: CORE_CONTRACT },
184
+ protocolVersion: MCP_PROTOCOL_VERSION,
185
+ serverInfo: {
186
+ name: HOST_ID,
187
+ version: PLUGIN_VERSION,
188
+ contract: CORE_CONTRACT,
189
+ minimumHostVersion: MIN_CLAUDE_CODE_VERSION,
190
+ },
172
191
  capabilities: { tools: {} },
173
192
  },
174
193
  };
@@ -191,13 +210,13 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
191
210
  const name = String(params.name ?? "");
192
211
  const interactive = mcp.sessionInteractive();
193
212
  if (isPrivilegedOperation(name) && !interactive) {
194
- throw new Error("non-interactive host session cannot mint authority");
213
+ throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
195
214
  }
196
- const identity = hostCallIdentity(params._meta, (toolCallId) => mcp.sessionOfElicitation(toolCallId));
215
+ const identity = hostCallIdentity(params._meta);
197
216
  if (isPrivilegedOperation(name) && !identity) {
198
- throw new Error("host correlation metadata missing");
217
+ throw new NativeAuthorityError("correlation_missing", "canonical claudecode/toolUseId metadata is missing");
199
218
  }
200
- const sessionId = identity?.sessionId ?? "stdio";
219
+ const sessionId = mcp.connectionId;
201
220
  const toolCallId = identity?.toolCallId ?? String(message.id ?? "stdio");
202
221
  const result = await mcp.callTool(name, params.arguments ?? {}, {
203
222
  sessionId,
@@ -212,11 +231,10 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
212
231
  result: { content: [{ type: "text", text: JSON.stringify(result) }] },
213
232
  };
214
233
  } catch (error) {
215
- const reason = error instanceof Error ? error.message : String(error);
216
234
  return {
217
235
  jsonrpc: "2.0",
218
236
  id: message.id ?? null,
219
- error: { code: -32000, message: reason },
237
+ error: rpcError(error),
220
238
  };
221
239
  } finally {
222
240
  mcp.aborts.delete(requestId);
@@ -228,8 +246,69 @@ export async function handleJsonRpc(message: JsonRpc, mcp = createMcpRuntime()):
228
246
 
229
247
  async function writeReply(output: Writable, reply: JsonRpc): Promise<void> {
230
248
  const payload = encodeMessage(reply);
231
- if (output.write(payload)) return;
232
- await new Promise<void>((resolve) => output.once("drain", resolve));
249
+ await new Promise<void>((resolve, reject) => {
250
+ if (!output.writable) {
251
+ reject(new Error("output stream is not writable"));
252
+ return;
253
+ }
254
+ let settled = false;
255
+ const cleanup = () => {
256
+ output.off("drain", onDrain);
257
+ output.off("error", onError);
258
+ output.off("close", onClose);
259
+ };
260
+ const onDrain = () => {
261
+ if (settled) return;
262
+ settled = true;
263
+ cleanup();
264
+ resolve();
265
+ };
266
+ const onError = (error: Error) => {
267
+ if (settled) return;
268
+ settled = true;
269
+ cleanup();
270
+ reject(error);
271
+ };
272
+ const onClose = () => {
273
+ if (settled) return;
274
+ settled = true;
275
+ cleanup();
276
+ reject(new Error("output stream closed before write drained"));
277
+ };
278
+ output.once("error", onError);
279
+ output.once("close", onClose);
280
+ const ok = output.write(payload, (error) => {
281
+ if (settled) return;
282
+ if (error) {
283
+ settled = true;
284
+ cleanup();
285
+ reject(error);
286
+ }
287
+ });
288
+ if (ok) {
289
+ settled = true;
290
+ cleanup();
291
+ resolve();
292
+ return;
293
+ }
294
+ output.once("drain", onDrain);
295
+ });
296
+ }
297
+
298
+ export function elicitationParams(input: NativeConfirmationInput) {
299
+ const details = [
300
+ `Operation: ${input.operation}`,
301
+ `Task: ${input.taskId}`,
302
+ input.risk ? `Risk: ${input.risk}` : null,
303
+ input.intentRevision !== undefined ? `Intent revision: ${input.intentRevision}` : null,
304
+ input.intentContentHash ? `Intent hash: ${input.intentContentHash}` : null,
305
+ input.bindingDigest ? `Binding digest: ${input.bindingDigest}` : null,
306
+ ].filter(Boolean);
307
+ return {
308
+ mode: "form",
309
+ message: `Authorize this exact Immune-Brain operation?\n\n${details.join("\n")}`,
310
+ requestedSchema: { type: "object", properties: {} },
311
+ };
233
312
  }
234
313
 
235
314
  export async function serveStdio(options: {
@@ -244,17 +323,93 @@ export async function serveStdio(options: {
244
323
  const exit = options.exit ?? ((code: number) => { process.exit(code); });
245
324
  let buffer = Buffer.alloc(0);
246
325
  let accepting = true;
326
+ let requestSequence = 0;
247
327
  const inFlight = new Set<Promise<void>>();
328
+ const pending = new Map<string, {
329
+ resolve: (response: JsonRpc) => void;
330
+ reject: (error: Error) => void;
331
+ }>();
248
332
  let chain = Promise.resolve();
249
333
 
334
+ const rejectPending = (error: Error): void => {
335
+ for (const item of pending.values()) item.reject(error);
336
+ pending.clear();
337
+ };
338
+
339
+ mcp.bindNativeConfirmation(async (input) => {
340
+ if (!accepting) throw new NativeAuthorityError("interaction_not_opened", "MCP connection is closed");
341
+ if (input.signal?.aborted) throw new NativeAuthorityError("user_cancelled", "Tool call was cancelled");
342
+ const requestId = `immune-brain:elicitation:${mcp.connectionId}:${++requestSequence}`;
343
+ let abortListener: (() => void) | undefined;
344
+ const response = new Promise<JsonRpc>((resolve, reject) => {
345
+ pending.set(requestId, { resolve, reject });
346
+ abortListener = () => {
347
+ pending.delete(requestId);
348
+ reject(new NativeAuthorityError("user_cancelled", "Tool call was cancelled"));
349
+ };
350
+ input.signal?.addEventListener("abort", abortListener, { once: true });
351
+ });
352
+ try {
353
+ await writeReply(output, {
354
+ jsonrpc: "2.0",
355
+ id: requestId,
356
+ method: "elicitation/create",
357
+ params: elicitationParams(input),
358
+ });
359
+ const reply = await response;
360
+ if (reply.error) {
361
+ if (reply.error.code === -32601) {
362
+ throw new NativeAuthorityError("unsupported_host", "Claude Code rejected MCP elicitation/create");
363
+ }
364
+ throw new NativeAuthorityError("correlation_missing", `MCP elicitation failed: ${reply.error.message}`);
365
+ }
366
+ const result = reply.result;
367
+ const action = typeof result === "object" && result !== null && !Array.isArray(result)
368
+ ? (result as { action?: unknown }).action
369
+ : undefined;
370
+ if (action !== "accept" && action !== "decline" && action !== "cancel") {
371
+ throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
372
+ }
373
+ if (action === "accept") {
374
+ const content = (result as { content?: unknown }).content;
375
+ if (typeof content !== "object" || content === null || Array.isArray(content) || Object.keys(content).length !== 0) {
376
+ throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested schema");
377
+ }
378
+ }
379
+ return { decision: action, requestId };
380
+ } finally {
381
+ pending.delete(requestId);
382
+ if (abortListener) input.signal?.removeEventListener("abort", abortListener);
383
+ }
384
+ });
385
+
250
386
  const runCall = (parsed: JsonRpc): void => {
251
387
  const task = handleJsonRpc(parsed, mcp).then(async (reply) => {
252
388
  if (reply) await writeReply(output, reply);
389
+ }).catch(() => {
390
+ void executeShutdown(1);
253
391
  });
254
392
  inFlight.add(task);
255
393
  void task.finally(() => inFlight.delete(task));
256
394
  };
257
395
 
396
+ const routeResponse = (obj: Record<string, unknown>): boolean => {
397
+ const id = typeof obj.id === "string" ? obj.id : "";
398
+ const waiter = id ? pending.get(id) : undefined;
399
+ if (waiter) {
400
+ pending.delete(id);
401
+ const hasResult = Object.hasOwn(obj, "result");
402
+ const hasError = Object.hasOwn(obj, "error");
403
+ if (obj.jsonrpc !== "2.0" || obj.method !== undefined || hasResult === hasError) {
404
+ waiter.reject(new NativeAuthorityError("correlation_missing", "malformed MCP elicitation response"));
405
+ } else {
406
+ waiter.resolve(obj as unknown as JsonRpc);
407
+ }
408
+ return true;
409
+ }
410
+ return obj.method === undefined && obj.id !== undefined;
411
+ };
412
+
258
413
  const drainStdio = async (): Promise<void> => {
259
414
  while (true) {
260
415
  const newline = buffer.indexOf("\n");
@@ -274,10 +429,12 @@ export async function serveStdio(options: {
274
429
  continue;
275
430
  }
276
431
  const obj = parsed as Record<string, unknown>;
432
+ if (routeResponse(obj)) continue;
277
433
  if (obj.jsonrpc !== "2.0") {
278
434
  await writeReply(output, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: jsonrpc must be '2.0'" } });
279
435
  continue;
280
436
  }
437
+ if (routeResponse(obj)) continue;
281
438
  if (typeof obj.method !== "string" || !obj.method) {
282
439
  const id = obj.id !== undefined && (typeof obj.id === "string" || typeof obj.id === "number") ? obj.id : null;
283
440
  await writeReply(output, { jsonrpc: "2.0", id, error: { code: -32600, message: "Invalid Request: missing method" } });
@@ -287,7 +444,6 @@ export async function serveStdio(options: {
287
444
  await writeReply(output, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: invalid id type" } });
288
445
  continue;
289
446
  }
290
- // tools/call is a request requiring a reply; notification-form tools/call (no id) is an invalid request
291
447
  if (obj.method === "tools/call" && obj.id === undefined) {
292
448
  await writeReply(output, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: tools/call requires an id" } });
293
449
  continue;
@@ -295,10 +451,15 @@ export async function serveStdio(options: {
295
451
  const rpc = parsed as JsonRpc;
296
452
  if (rpc.method === "notifications/cancelled") {
297
453
  const requestId = (rpc.params as { requestId?: string | number } | undefined)?.requestId;
298
- if (requestId !== undefined) mcp.aborts.get(requestId)?.abort(new Error("notifications/cancelled"));
454
+ if (typeof requestId === "string" && pending.has(requestId)) {
455
+ pending.get(requestId)?.reject(new NativeAuthorityError("user_cancelled", "native interaction was cancelled"));
456
+ pending.delete(requestId);
457
+ } else if (requestId !== undefined) {
458
+ mcp.aborts.get(requestId)?.abort(new Error("notifications/cancelled"));
459
+ }
299
460
  continue;
300
461
  }
301
- if (parsed.method === "tools/call") {
462
+ if (rpc.method === "tools/call") {
302
463
  if (!accepting) {
303
464
  if (rpc.id !== undefined) await writeReply(output, { jsonrpc: "2.0", id: rpc.id, error: { code: -32000, message: "stdio closed" } });
304
465
  continue;
@@ -311,38 +472,55 @@ export async function serveStdio(options: {
311
472
  }
312
473
  };
313
474
 
475
+ let resolveStdio = () => {};
314
476
  let shutdownPromise: Promise<void> | null = null;
315
477
  const executeShutdown = (code = 0): Promise<void> => {
316
478
  if (shutdownPromise) return shutdownPromise;
317
479
  shutdownPromise = (async () => {
318
480
  accepting = false;
319
481
  for (const ac of mcp.aborts.values()) ac.abort(new Error("stdio closed"));
320
- await Promise.allSettled([...inFlight]);
482
+ rejectPending(new NativeAuthorityError("user_cancelled", "MCP connection closed during native interaction"));
483
+ await Promise.race([
484
+ Promise.allSettled([...inFlight]),
485
+ new Promise<void>((resolve) => setTimeout(resolve, 200)),
486
+ ]);
321
487
  await mcp.shutdown();
322
488
  if (output.writable && typeof output.end === "function") {
323
489
  await new Promise<void>((cb) => output.end(() => cb()));
324
490
  }
325
491
  process.exitCode = code;
326
492
  exit(code);
493
+ resolveStdio();
327
494
  })();
328
495
  return shutdownPromise;
329
496
  };
330
497
 
331
498
  await new Promise<void>((resolve) => {
499
+ resolveStdio = resolve;
500
+ output.on("error", () => {
501
+ void executeShutdown(1);
502
+ });
503
+ output.on("close", () => {
504
+ void executeShutdown(0);
505
+ });
332
506
  input.on("data", (chunk: Buffer | string) => {
507
+ if (!accepting) return;
333
508
  chain = chain.then(async () => {
509
+ if (!accepting) return;
334
510
  buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
335
511
  await drainStdio();
512
+ }).catch(() => {
513
+ void executeShutdown(1);
336
514
  });
337
515
  });
338
516
  input.on("end", () => {
339
- void chain.then(() => executeShutdown(0)).finally(resolve);
517
+ void chain.then(() => executeShutdown(0));
340
518
  });
341
519
  input.on("close", () => {
342
- void chain.then(() => executeShutdown(0)).finally(resolve);
520
+ void chain.then(() => executeShutdown(0));
343
521
  });
344
522
  input.on("error", () => {
345
- void chain.then(() => executeShutdown(1)).finally(resolve);
523
+ void executeShutdown(1);
346
524
  });
347
525
  });
348
526
  }