@khalilgharbaoui/opencode-claude-code-plugin 0.3.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -114,6 +114,7 @@ interface ClaudeCodeConfig {
114
114
  proxyTools?: string[];
115
115
  webSearch?: WebSearchRouting;
116
116
  hotReloadMcp?: boolean;
117
+ proxyOpencodeMcpTools?: boolean;
117
118
  }
118
119
  type WebSearchRouting = "claude" | "disabled" | (string & {});
119
120
  interface ClaudeCodeProviderSettings {
@@ -191,6 +192,21 @@ interface ClaudeCodeProviderSettings {
191
192
  * survives MCP changes until the chat is reset).
192
193
  */
193
194
  hotReloadMcp?: boolean;
195
+ /**
196
+ * Route opencode MCP server tools through the in-process `opencode_proxy`
197
+ * MCP server instead of bridging them directly into Claude CLI's
198
+ * `--mcp-config`. With both layers configured for the same MCP server,
199
+ * direct bridging causes each tool invocation to execute twice — once by
200
+ * Claude CLI's own MCP child process and once by opencode. Routing through
201
+ * the proxy keeps a single execution site (opencode) while preserving the
202
+ * tool-call/result surface in opencode's UI and its permission prompts.
203
+ *
204
+ * Defaults to `true`. Set to `false` to restore the prior direct-bridge
205
+ * behavior (Claude CLI executes MCP tools itself; opencode also re-executes
206
+ * — accept the duplication if you need Claude to invoke the tool without
207
+ * an opencode round-trip).
208
+ */
209
+ proxyOpencodeMcpTools?: boolean;
194
210
  }
195
211
  type PermissionMode = "acceptEdits" | "auto" | "bypassPermissions" | "default" | "dontAsk" | "plan";
196
212
  type ControlRequestBehavior = "allow" | "deny";
@@ -305,6 +321,16 @@ declare class ClaudeCodeLanguageModel implements LanguageModelV3 {
305
321
  private effectiveMcpConfig;
306
322
  /** Resolve ProxyToolDef[] for the configured proxyTools names. */
307
323
  private resolvedProxyTools;
324
+ /**
325
+ * Resolve ProxyToolDef[] for opencode's MCP-bridged tools so they go
326
+ * through the in-process proxy instead of being bridged into Claude CLI's
327
+ * `--mcp-config`. Direct bridging causes double execution because both
328
+ * Claude CLI's own MCP child and opencode hold their own connection to
329
+ * the same server; routing through the proxy keeps a single execution
330
+ * site (opencode). Returns null when the feature is disabled, the SDK
331
+ * client is unavailable, or no MCP servers are configured.
332
+ */
333
+ private resolvedProxyMcpTools;
308
334
  /**
309
335
  * Create a proxy MCP server for a single active Claude process/session.
310
336
  * The process lifecycle owns the server lifecycle via session-manager.
@@ -339,6 +365,18 @@ interface BridgedMcp {
339
365
  path: string;
340
366
  /** Stable hash of the merged opencode mcp block (pre-translation). */
341
367
  hash: string;
368
+ /**
369
+ * Names of opencode MCP servers that were bridged into Claude CLI's
370
+ * `--mcp-config`. Excludes any servers passed in `excludeServers`.
371
+ */
372
+ serverNames: string[];
373
+ /**
374
+ * Names of every enabled opencode MCP server after merge + runtime
375
+ * overlay, regardless of whether they ended up bridged or excluded.
376
+ * Callers (e.g. the proxy-tool builder) use this to decide which
377
+ * `<server>_<tool>` IDs in opencode's tool catalog are MCP-origin.
378
+ */
379
+ allEnabledServerNames: string[];
342
380
  }
343
381
  /**
344
382
  * Per-server runtime status from opencode's `client.mcp.status()`. Used as
@@ -362,7 +400,7 @@ type RuntimeMcpStatus = Record<string, string>;
362
400
  * return its path + a stable hash. Returns null when no enabled MCP servers
363
401
  * remain after the merge + overlay.
364
402
  */
365
- declare function bridgeOpencodeMcp(cwd: string, runtimeStatus?: RuntimeMcpStatus): BridgedMcp | null;
403
+ declare function bridgeOpencodeMcp(cwd: string, runtimeStatus?: RuntimeMcpStatus, excludeServers?: ReadonlySet<string>): BridgedMcp | null;
366
404
 
367
405
  declare const defaultModels: Record<string, OpenCodeModel>;
368
406
 
package/dist/index.js CHANGED
@@ -637,7 +637,7 @@ function mergeMcp(target, source) {
637
637
  }
638
638
  return out;
639
639
  }
640
- function bridgeOpencodeMcp(cwd, runtimeStatus) {
640
+ function bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers) {
641
641
  const worktree = detectWorktree(cwd);
642
642
  let merged = {};
643
643
  merged = mergeMcp(merged, extractMcpBlock(loadGlobalConfig()));
@@ -676,15 +676,37 @@ function bridgeOpencodeMcp(cwd, runtimeStatus) {
676
676
  merged[name] = { ...base, enabled: status === "connected" };
677
677
  }
678
678
  }
679
+ const allEnabledServerNames = [];
680
+ for (const [name, spec] of Object.entries(merged)) {
681
+ if (!spec || typeof spec !== "object") continue;
682
+ const enabled = spec.enabled;
683
+ if (enabled === false) continue;
684
+ allEnabledServerNames.push(name);
685
+ }
679
686
  const servers = {};
687
+ const bridgedServerNames = [];
680
688
  for (const [name, spec] of Object.entries(merged)) {
681
689
  if (!spec || typeof spec !== "object") continue;
690
+ if (excludeServers?.has(name)) continue;
682
691
  const translated = translateServer(name, spec);
683
- if (translated) servers[name] = translated;
692
+ if (translated) {
693
+ servers[name] = translated;
694
+ bridgedServerNames.push(name);
695
+ }
696
+ }
697
+ const mergedBody = JSON.stringify({ mcpServers: merged }, null, 2);
698
+ const hash = crypto.createHash("sha256").update(mergedBody).digest("hex").slice(0, 12);
699
+ if (Object.keys(servers).length === 0) {
700
+ const allEnabledServersExcluded = excludeServers && allEnabledServerNames.length > 0 && allEnabledServerNames.every((name) => excludeServers.has(name));
701
+ if (!allEnabledServersExcluded) return null;
702
+ return {
703
+ path: "",
704
+ hash,
705
+ serverNames: [],
706
+ allEnabledServerNames
707
+ };
684
708
  }
685
- if (Object.keys(servers).length === 0) return null;
686
709
  const body = JSON.stringify({ mcpServers: servers }, null, 2);
687
- const hash = crypto.createHash("sha256").update(body).digest("hex").slice(0, 12);
688
710
  const outPath = path2.join(
689
711
  pluginTmpDir(),
690
712
  `mcp-${hash}.json`
@@ -702,9 +724,15 @@ function bridgeOpencodeMcp(cwd, runtimeStatus) {
702
724
  log.info("bridged opencode MCP config", {
703
725
  target: outPath,
704
726
  hash,
705
- servers: Object.keys(servers)
727
+ servers: bridgedServerNames,
728
+ excluded: excludeServers ? Array.from(excludeServers) : []
706
729
  });
707
- return { path: outPath, hash };
730
+ return {
731
+ path: outPath,
732
+ hash,
733
+ serverNames: bridgedServerNames,
734
+ allEnabledServerNames
735
+ };
708
736
  }
709
737
 
710
738
  // src/runtime-status.ts
@@ -736,6 +764,35 @@ async function getRuntimeMcpStatus() {
736
764
  return void 0;
737
765
  }
738
766
  }
767
+ async function fetchOpencodeToolList(provider, model, directory) {
768
+ const client = opencodeClient;
769
+ if (!client?.tool?.list) return void 0;
770
+ try {
771
+ const res = await client.tool.list({
772
+ query: { provider, model, ...directory ? { directory } : {} }
773
+ });
774
+ const data = res.data;
775
+ if (!Array.isArray(data)) return void 0;
776
+ const out = [];
777
+ for (const entry of data) {
778
+ if (!entry || typeof entry !== "object") continue;
779
+ const e = entry;
780
+ const id = typeof e.id === "string" ? e.id : null;
781
+ const description = typeof e.description === "string" ? e.description : "";
782
+ const parameters = e.parameters && typeof e.parameters === "object" ? e.parameters : {};
783
+ if (!id) continue;
784
+ out.push({ id, description, parameters });
785
+ }
786
+ return out;
787
+ } catch (err) {
788
+ log.warn("failed to fetch opencode tool list", {
789
+ provider,
790
+ model,
791
+ error: err instanceof Error ? err.message : String(err)
792
+ });
793
+ return void 0;
794
+ }
795
+ }
739
796
 
740
797
  // src/session-manager.ts
741
798
  import { spawn } from "child_process";
@@ -1262,33 +1319,71 @@ function writeJson(res, body) {
1262
1319
 
1263
1320
  // src/proxy-broker.ts
1264
1321
  import { EventEmitter as EventEmitter3 } from "events";
1265
- var pendingBySession = /* @__PURE__ */ new Map();
1322
+ var pendingByCallId = /* @__PURE__ */ new Map();
1323
+ var callIdsBySession = /* @__PURE__ */ new Map();
1266
1324
  var emitter = new EventEmitter3();
1325
+ var PENDING_PROXY_CALL_TIMEOUT_MS = 10 * 60 * 1e3;
1267
1326
  function eventName(sessionKey2) {
1268
1327
  return `pending:${sessionKey2}`;
1269
1328
  }
1329
+ function indexAdd(sessionKey2, callId) {
1330
+ let s = callIdsBySession.get(sessionKey2);
1331
+ if (!s) {
1332
+ s = /* @__PURE__ */ new Set();
1333
+ callIdsBySession.set(sessionKey2, s);
1334
+ }
1335
+ s.add(callId);
1336
+ }
1337
+ function indexRemove(sessionKey2, callId) {
1338
+ const s = callIdsBySession.get(sessionKey2);
1339
+ if (!s) return;
1340
+ s.delete(callId);
1341
+ if (s.size === 0) callIdsBySession.delete(sessionKey2);
1342
+ }
1270
1343
  function onPendingProxyCall(sessionKey2, handler) {
1271
1344
  const name = eventName(sessionKey2);
1272
1345
  emitter.on(name, handler);
1273
1346
  return () => emitter.off(name, handler);
1274
1347
  }
1275
1348
  function queuePendingProxyCall(sessionKey2, call) {
1276
- const existing = pendingBySession.get(sessionKey2);
1277
- if (existing) {
1278
- existing.reject(
1279
- new Error(`Another proxy tool call is already pending for ${sessionKey2}`)
1349
+ const previous = pendingByCallId.get(call.id);
1350
+ if (previous) {
1351
+ clearTimeout(previous.timer);
1352
+ previous.reject(
1353
+ new Error(`Replaced pending proxy call ${call.id} with a fresh one`)
1280
1354
  );
1281
- pendingBySession.delete(sessionKey2);
1282
- }
1355
+ pendingByCallId.delete(call.id);
1356
+ indexRemove(previous.sessionKey, call.id);
1357
+ }
1358
+ const timer = setTimeout(() => {
1359
+ const current = pendingByCallId.get(call.id);
1360
+ if (!current) return;
1361
+ pendingByCallId.delete(call.id);
1362
+ indexRemove(current.sessionKey, call.id);
1363
+ current.reject(
1364
+ new Error(
1365
+ `Proxy tool call '${call.toolName}' timed out after ${PENDING_PROXY_CALL_TIMEOUT_MS}ms waiting for opencode to resolve the call`
1366
+ )
1367
+ );
1368
+ log.warn("timed out pending proxy call", {
1369
+ sessionKey: current.sessionKey,
1370
+ toolCallId: call.id,
1371
+ toolName: call.toolName,
1372
+ timeoutMs: PENDING_PROXY_CALL_TIMEOUT_MS
1373
+ });
1374
+ }, PENDING_PROXY_CALL_TIMEOUT_MS);
1283
1375
  const pending = {
1284
1376
  sessionKey: sessionKey2,
1285
1377
  toolCallId: call.id,
1286
1378
  toolName: call.toolName,
1287
1379
  input: call.input,
1380
+ createdAt: Date.now(),
1381
+ timer,
1288
1382
  resolve: call.resolve,
1289
1383
  reject: call.reject
1290
1384
  };
1291
- pendingBySession.set(sessionKey2, pending);
1385
+ pendingByCallId.set(call.id, pending);
1386
+ indexAdd(sessionKey2, call.id);
1292
1387
  emitter.emit(eventName(sessionKey2), pending);
1293
1388
  log.info("queued pending proxy call", {
1294
1389
  sessionKey: sessionKey2,
@@ -1297,21 +1392,55 @@ function queuePendingProxyCall(sessionKey2, call) {
1297
1392
  });
1298
1393
  return pending;
1299
1394
  }
1300
- function getPendingProxyCall(sessionKey2) {
1301
- return pendingBySession.get(sessionKey2);
1395
+ function getPendingProxyCalls(sessionKey2) {
1396
+ const s = callIdsBySession.get(sessionKey2);
1397
+ if (!s || s.size === 0) return [];
1398
+ const out = [];
1399
+ for (const id of s) {
1400
+ const p = pendingByCallId.get(id);
1401
+ if (p) out.push(p);
1402
+ }
1403
+ return out;
1302
1404
  }
1303
- function resolvePendingProxyCall(sessionKey2, result) {
1304
- const pending = pendingBySession.get(sessionKey2);
1405
+ function resolvePendingProxyCallById(toolCallId, result) {
1406
+ const pending = pendingByCallId.get(toolCallId);
1305
1407
  if (!pending) return false;
1306
- pendingBySession.delete(sessionKey2);
1408
+ pendingByCallId.delete(toolCallId);
1409
+ indexRemove(pending.sessionKey, toolCallId);
1410
+ clearTimeout(pending.timer);
1307
1411
  pending.resolve(result);
1308
1412
  log.info("resolved pending proxy call", {
1309
- sessionKey: sessionKey2,
1413
+ sessionKey: pending.sessionKey,
1310
1414
  toolCallId: pending.toolCallId,
1311
1415
  toolName: pending.toolName
1312
1416
  });
1313
1417
  return true;
1314
1418
  }
1419
+ function rejectPendingProxyCallById(toolCallId, error) {
1420
+ const pending = pendingByCallId.get(toolCallId);
1421
+ if (!pending) return false;
1422
+ pendingByCallId.delete(toolCallId);
1423
+ indexRemove(pending.sessionKey, toolCallId);
1424
+ clearTimeout(pending.timer);
1425
+ pending.reject(error);
1426
+ log.warn("rejected pending proxy call", {
1427
+ sessionKey: pending.sessionKey,
1428
+ toolCallId: pending.toolCallId,
1429
+ toolName: pending.toolName,
1430
+ error: error.message
1431
+ });
1432
+ return true;
1433
+ }
1434
+ function rejectAllPendingProxyCallsForSession(sessionKey2, error) {
1435
+ const s = callIdsBySession.get(sessionKey2);
1436
+ if (!s) return 0;
1437
+ const ids = [...s];
1438
+ let count = 0;
1439
+ for (const id of ids) {
1440
+ if (rejectPendingProxyCallById(id, error)) count++;
1441
+ }
1442
+ return count;
1443
+ }
1315
1444
 
1316
1445
  // src/claude-code-language-model.ts
1317
1446
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
@@ -1432,18 +1561,20 @@ var ClaudeCodeLanguageModel = class {
1432
1561
  * provided it overlays opencode's UI-toggled state on top of disk config
1433
1562
  * so `/mcps` toggles propagate without a config file write.
1434
1563
  */
1435
- effectiveMcpConfig(cwd, proxyConfigPath, runtimeStatus) {
1564
+ effectiveMcpConfig(cwd, proxyConfigPath, runtimeStatus, excludeServers) {
1436
1565
  const paths = Array.isArray(this.config.mcpConfig) ? this.config.mcpConfig.slice() : this.config.mcpConfig ? [this.config.mcpConfig] : [];
1437
1566
  let bridgedHash = null;
1567
+ let allEnabledServerNames = [];
1438
1568
  if (this.config.bridgeOpencodeMcp !== false) {
1439
- const bridged = bridgeOpencodeMcp(cwd, runtimeStatus);
1569
+ const bridged = bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers);
1440
1570
  if (bridged) {
1441
- paths.push(bridged.path);
1571
+ if (bridged.path) paths.push(bridged.path);
1442
1572
  bridgedHash = bridged.hash;
1573
+ allEnabledServerNames = bridged.allEnabledServerNames;
1443
1574
  }
1444
1575
  }
1445
1576
  if (proxyConfigPath) paths.push(proxyConfigPath);
1446
- return { paths, bridgedHash };
1577
+ return { paths, bridgedHash, allEnabledServerNames };
1447
1578
  }
1448
1579
  /** Resolve ProxyToolDef[] for the configured proxyTools names. */
1449
1580
  resolvedProxyTools() {
@@ -1459,6 +1590,45 @@ var ClaudeCodeLanguageModel = class {
1459
1590
  }
1460
1591
  return picked.length > 0 ? picked : null;
1461
1592
  }
1593
+ /**
1594
+ * Resolve ProxyToolDef[] for opencode's MCP-bridged tools so they go
1595
+ * through the in-process proxy instead of being bridged into Claude CLI's
1596
+ * `--mcp-config`. Direct bridging causes double execution because both
1597
+ * Claude CLI's own MCP child and opencode hold their own connection to
1598
+ * the same server; routing through the proxy keeps a single execution
1599
+ * site (opencode). Returns null when the feature is disabled, the SDK
1600
+ * client is unavailable, or no MCP servers are configured.
1601
+ */
1602
+ async resolvedProxyMcpTools(allEnabledServerNames) {
1603
+ if (this.config.proxyOpencodeMcpTools === false) return null;
1604
+ if (this.config.bridgeOpencodeMcp === false) return null;
1605
+ if (allEnabledServerNames.length === 0) return null;
1606
+ const items = await fetchOpencodeToolList(
1607
+ this.config.provider,
1608
+ this.modelId,
1609
+ this.config.cwd
1610
+ );
1611
+ if (!items || items.length === 0) return null;
1612
+ const serversByLengthDesc = [...allEnabledServerNames].sort(
1613
+ (a, b) => b.length - a.length
1614
+ );
1615
+ const out = [];
1616
+ const seen = /* @__PURE__ */ new Set();
1617
+ for (const item of items) {
1618
+ const matchedServer = serversByLengthDesc.find(
1619
+ (name) => item.id === name || item.id.startsWith(`${name}_`)
1620
+ );
1621
+ if (!matchedServer) continue;
1622
+ if (seen.has(item.id)) continue;
1623
+ seen.add(item.id);
1624
+ out.push({
1625
+ name: item.id,
1626
+ description: item.description ?? "",
1627
+ inputSchema: item.parameters && typeof item.parameters === "object" ? item.parameters : { type: "object", properties: {} }
1628
+ });
1629
+ }
1630
+ return out.length > 0 ? out : null;
1631
+ }
1462
1632
  /**
1463
1633
  * Create a proxy MCP server for a single active Claude process/session.
1464
1634
  * The process lifecycle owns the server lifecycle via session-manager.
@@ -1740,7 +1910,7 @@ var ClaudeCodeLanguageModel = class {
1740
1910
  const scope = this.requestScope(options);
1741
1911
  const affinity = this.sessionAffinity(options);
1742
1912
  const sk = sessionKey(cwd, `${this.modelId}::${scope}::${affinity}`);
1743
- if (scope === "tools" && this.resolvedProxyTools()) {
1913
+ if (scope === "tools" && (this.resolvedProxyTools() || this.config.proxyOpencodeMcpTools !== false && this.config.bridgeOpencodeMcp !== false)) {
1744
1914
  return this.doGenerateViaStream(options);
1745
1915
  }
1746
1916
  if (scope === "no-tools") {
@@ -2104,8 +2274,14 @@ ${plan}
2104
2274
  );
2105
2275
  const resolvedProxy = this.resolvedProxyTools();
2106
2276
  const self = this;
2107
- const pendingProxyCall = getPendingProxyCall(sk);
2108
- const pendingProxyResult = pendingProxyCall ? this.extractPendingProxyResult(options.prompt, pendingProxyCall.toolCallId) : null;
2277
+ const previousPendingProxyCalls = getPendingProxyCalls(sk);
2278
+ const previousPendingProxyMatches = previousPendingProxyCalls.map((call) => ({
2279
+ call,
2280
+ result: this.extractPendingProxyResult(options.prompt, call.toolCallId)
2281
+ }));
2282
+ const hasMatchedPendingResults = previousPendingProxyMatches.some(
2283
+ (m) => m.result !== null
2284
+ );
2109
2285
  const runtimeStatus = await getRuntimeMcpStatus();
2110
2286
  log.info("doStream starting", {
2111
2287
  cwd,
@@ -2137,8 +2313,18 @@ ${plan}
2137
2313
  }
2138
2314
  }
2139
2315
  const setup = async () => {
2140
- if (!proxyServer && resolvedProxy) {
2141
- proxyServer = await self.ensureProxyServer(resolvedProxy, sk);
2316
+ const discovery = self.effectiveMcpConfig(
2317
+ cwd,
2318
+ void 0,
2319
+ runtimeStatus
2320
+ );
2321
+ const proxyMcpTools = await self.resolvedProxyMcpTools(
2322
+ discovery.allEnabledServerNames
2323
+ );
2324
+ const excludeServers = proxyMcpTools ? new Set(discovery.allEnabledServerNames) : void 0;
2325
+ const combinedProxyTools = resolvedProxy || proxyMcpTools ? [...resolvedProxy ?? [], ...proxyMcpTools ?? []] : null;
2326
+ if (!proxyServer && combinedProxyTools) {
2327
+ proxyServer = await self.ensureProxyServer(combinedProxyTools, sk);
2142
2328
  }
2143
2329
  const proxyDisallowed = resolvedProxy ? disallowedToolFlags(resolvedProxy) : [];
2144
2330
  const extraDisallowed = [];
@@ -2147,7 +2333,8 @@ ${plan}
2147
2333
  const mcp = self.effectiveMcpConfig(
2148
2334
  cwd,
2149
2335
  proxyServer?.configPath(),
2150
- runtimeStatus
2336
+ runtimeStatus,
2337
+ excludeServers
2151
2338
  );
2152
2339
  const systemPromptFile = activeProcess ? void 0 : buildAppendedSystemPrompt(cwd);
2153
2340
  const cliArgs = buildCliArgs({
@@ -2224,21 +2411,27 @@ ${plan}
2224
2411
  const skipResultForIds = /* @__PURE__ */ new Set();
2225
2412
  const toolCallsById = /* @__PURE__ */ new Map();
2226
2413
  let resultMeta = {};
2227
- const finishWithToolCall = (call) => {
2414
+ const drainBuffer = [];
2415
+ let drainTimer = null;
2416
+ const DRAIN_QUIET_MS = 100;
2417
+ const finishWithToolCalls = (calls) => {
2228
2418
  if (controllerClosed) return;
2229
- controller.enqueue({
2230
- type: "tool-input-start",
2231
- id: call.toolCallId,
2232
- toolName: call.toolName
2233
- });
2234
- controller.enqueue({
2235
- type: "tool-call",
2236
- toolCallId: call.toolCallId,
2237
- toolName: call.toolName,
2238
- input: JSON.stringify(call.input),
2239
- providerExecuted: false
2240
- });
2241
- skipResultForIds.add(call.toolCallId);
2419
+ if (calls.length === 0) return;
2420
+ for (const call of calls) {
2421
+ controller.enqueue({
2422
+ type: "tool-input-start",
2423
+ id: call.toolCallId,
2424
+ toolName: call.toolName
2425
+ });
2426
+ controller.enqueue({
2427
+ type: "tool-call",
2428
+ toolCallId: call.toolCallId,
2429
+ toolName: call.toolName,
2430
+ input: JSON.stringify(call.input),
2431
+ providerExecuted: false
2432
+ });
2433
+ skipResultForIds.add(call.toolCallId);
2434
+ }
2242
2435
  controller.enqueue({
2243
2436
  type: "finish",
2244
2437
  finishReason: toFinishReason("tool-calls"),
@@ -2254,6 +2447,21 @@ ${plan}
2254
2447
  } catch {
2255
2448
  }
2256
2449
  };
2450
+ const drainNow = () => {
2451
+ if (drainTimer) {
2452
+ clearTimeout(drainTimer);
2453
+ drainTimer = null;
2454
+ }
2455
+ if (drainBuffer.length === 0) return;
2456
+ if (controllerClosed) return;
2457
+ const batch = drainBuffer.splice(0, drainBuffer.length);
2458
+ log.info("draining pending proxy calls into stream finish", {
2459
+ sessionKey: sk,
2460
+ count: batch.length,
2461
+ toolCallIds: batch.map((c) => c.toolCallId)
2462
+ });
2463
+ finishWithToolCalls(batch);
2464
+ };
2257
2465
  let gotPartialEvents = false;
2258
2466
  const lineHandler = (line) => {
2259
2467
  if (!line.trim()) return;
@@ -2686,6 +2894,15 @@ ${plan}
2686
2894
  const closeHandler = () => {
2687
2895
  log.debug("readline closed");
2688
2896
  if (controllerClosed) return;
2897
+ if (drainBuffer.length > 0 || getPendingProxyCalls(sk).length > 0) {
2898
+ rejectAllPendingProxyCallsForSession(
2899
+ sk,
2900
+ new Error(
2901
+ "Claude CLI subprocess closed before pending tool calls were resolved"
2902
+ )
2903
+ );
2904
+ drainBuffer.length = 0;
2905
+ }
2689
2906
  controllerClosed = true;
2690
2907
  cleanupTurn();
2691
2908
  endTextBlock();
@@ -2707,6 +2924,10 @@ ${plan}
2707
2924
  if (cleanedUp) return;
2708
2925
  cleanedUp = true;
2709
2926
  clearFallbackTimer();
2927
+ if (drainTimer) {
2928
+ clearTimeout(drainTimer);
2929
+ drainTimer = null;
2930
+ }
2710
2931
  lineEmitter.off("line", lineHandler);
2711
2932
  lineEmitter.off("close", closeHandler);
2712
2933
  pendingProxyUnsubscribe?.();
@@ -2716,6 +2937,15 @@ ${plan}
2716
2937
  const procErrorHandler = (err) => {
2717
2938
  log.error("process error", { error: err.message });
2718
2939
  if (controllerClosed) return;
2940
+ if (drainBuffer.length > 0 || getPendingProxyCalls(sk).length > 0) {
2941
+ rejectAllPendingProxyCallsForSession(
2942
+ sk,
2943
+ new Error(
2944
+ `Claude CLI subprocess error: ${err.message}`
2945
+ )
2946
+ );
2947
+ drainBuffer.length = 0;
2948
+ }
2719
2949
  controllerClosed = true;
2720
2950
  cleanupTurn();
2721
2951
  controller.enqueue({ type: "error", error: err });
@@ -2727,12 +2957,31 @@ ${plan}
2727
2957
  lineEmitter.on("line", lineHandler);
2728
2958
  lineEmitter.on("close", closeHandler);
2729
2959
  pendingProxyUnsubscribe = onPendingProxyCall(sk, (call) => {
2960
+ if (controllerClosed) {
2961
+ log.warn(
2962
+ "pending proxy call arrived after stream close; rejecting",
2963
+ {
2964
+ sessionKey: sk,
2965
+ toolCallId: call.toolCallId,
2966
+ toolName: call.toolName
2967
+ }
2968
+ );
2969
+ rejectPendingProxyCallById(
2970
+ call.toolCallId,
2971
+ new Error(
2972
+ `Pending proxy call '${call.toolName}' arrived after the stream was already closed`
2973
+ )
2974
+ );
2975
+ return;
2976
+ }
2730
2977
  log.info("received pending proxy call for session", {
2731
2978
  sessionKey: sk,
2732
2979
  toolCallId: call.toolCallId,
2733
2980
  toolName: call.toolName
2734
2981
  });
2735
- finishWithToolCall(call);
2982
+ drainBuffer.push(call);
2983
+ if (drainTimer) clearTimeout(drainTimer);
2984
+ drainTimer = setTimeout(drainNow, DRAIN_QUIET_MS);
2736
2985
  });
2737
2986
  proc.on("error", procErrorHandler);
2738
2987
  if (options.abortSignal) {
@@ -2758,21 +3007,44 @@ ${plan}
2758
3007
  startResultFallback(5e3);
2759
3008
  });
2760
3009
  }
2761
- if (pendingProxyCall && pendingProxyResult) {
2762
- log.info("resolving pending proxy call from tool result prompt", {
2763
- sessionKey: sk,
2764
- toolCallId: pendingProxyCall.toolCallId,
2765
- toolName: pendingProxyCall.toolName
2766
- });
2767
- const resolved = resolvePendingProxyCall(sk, pendingProxyResult);
2768
- if (!resolved) {
2769
- log.warn("failed to resolve pending proxy call; no pending state", {
2770
- sessionKey: sk,
2771
- toolCallId: pendingProxyCall.toolCallId
2772
- });
3010
+ if (hasMatchedPendingResults) {
3011
+ for (const { call, result } of previousPendingProxyMatches) {
3012
+ if (result) {
3013
+ log.info("resolving pending proxy call from tool result prompt", {
3014
+ sessionKey: sk,
3015
+ toolCallId: call.toolCallId,
3016
+ toolName: call.toolName
3017
+ });
3018
+ resolvePendingProxyCallById(call.toolCallId, result);
3019
+ } else {
3020
+ log.warn(
3021
+ "pending proxy call had no matching tool-result; rejecting as orphan",
3022
+ {
3023
+ sessionKey: sk,
3024
+ toolCallId: call.toolCallId,
3025
+ toolName: call.toolName
3026
+ }
3027
+ );
3028
+ rejectPendingProxyCallById(
3029
+ call.toolCallId,
3030
+ new Error(
3031
+ `Pending proxy call '${call.toolName}' (${call.toolCallId}) was not matched in tool-result turn; rejecting as orphaned`
3032
+ )
3033
+ );
3034
+ }
2773
3035
  }
2774
3036
  return;
2775
3037
  }
3038
+ if (previousPendingProxyCalls.length > 0) {
3039
+ for (const call of previousPendingProxyCalls) {
3040
+ rejectPendingProxyCallById(
3041
+ call.toolCallId,
3042
+ new Error(
3043
+ `Pending proxy call '${call.toolName}' (${call.toolCallId}) was orphaned by a new user turn; rejecting`
3044
+ )
3045
+ );
3046
+ }
3047
+ }
2776
3048
  proc.stdin?.write(userMsg + "\n");
2777
3049
  log.debug("sent user message", { textLength: userMsg.length });
2778
3050
  };
@@ -3225,7 +3497,8 @@ function createClaudeCode(settings = {}) {
3225
3497
  controlRequestDenyMessage: settings.controlRequestDenyMessage,
3226
3498
  proxyTools,
3227
3499
  webSearch: settings.webSearch,
3228
- hotReloadMcp: settings.hotReloadMcp ?? true
3500
+ hotReloadMcp: settings.hotReloadMcp ?? true,
3501
+ proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools ?? true
3229
3502
  });
3230
3503
  };
3231
3504
  const provider = function(modelId) {