@khalilgharbaoui/opencode-claude-code-plugin 0.14.1 → 0.15.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.
package/dist/index.js CHANGED
@@ -376,18 +376,241 @@ function mapTool(name, input, opts) {
376
376
  return { name, input, executed: true };
377
377
  }
378
378
 
379
+ // src/side-question.ts
380
+ import { randomUUID } from "crypto";
381
+
382
+ // src/cli-version.ts
383
+ import { execFile } from "child_process";
384
+ import { promisify } from "util";
385
+ var execFileAsync = promisify(execFile);
386
+ var cache = /* @__PURE__ */ new Map();
387
+ function detectCliVersion(cliPath) {
388
+ const cached = cache.get(cliPath);
389
+ if (cached) return cached;
390
+ const promise = (async () => {
391
+ try {
392
+ const { stdout } = await execFileAsync(cliPath, ["--version"], {
393
+ timeout: 5e3
394
+ });
395
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout.trim());
396
+ if (!match) {
397
+ log.warn("claude --version output unparseable", { stdout: stdout.trim() });
398
+ return null;
399
+ }
400
+ const v = {
401
+ major: Number(match[1]),
402
+ minor: Number(match[2]),
403
+ patch: Number(match[3]),
404
+ raw: stdout.trim()
405
+ };
406
+ log.info("detected claude cli version", { cliPath, version: v.raw });
407
+ if (!cliSupportsThinkingDisplay(v)) {
408
+ log.notice(
409
+ "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
410
+ { version: v.raw }
411
+ );
412
+ }
413
+ return v;
414
+ } catch (err) {
415
+ log.warn("failed to detect claude cli version", {
416
+ cliPath,
417
+ error: err instanceof Error ? err.message : String(err)
418
+ });
419
+ return null;
420
+ }
421
+ })();
422
+ cache.set(cliPath, promise);
423
+ return promise;
424
+ }
425
+ function gte(v, target) {
426
+ if (v.major !== target.major) return v.major > target.major;
427
+ if (v.minor !== target.minor) return v.minor > target.minor;
428
+ return v.patch >= target.patch;
429
+ }
430
+ function cliSupportsThinkingDisplay(v) {
431
+ if (!v) return false;
432
+ return gte(v, { major: 2, minor: 1, patch: 142 });
433
+ }
434
+ function cliSupportsFastMode(v) {
435
+ if (!v) return false;
436
+ return gte(v, { major: 2, minor: 1, patch: 220 });
437
+ }
438
+ function cliSupportsSideQuestion(v) {
439
+ if (!v) return false;
440
+ return gte(v, { major: 2, minor: 1, patch: 258 });
441
+ }
442
+ function cliSupportsThinking(v) {
443
+ if (!v) return false;
444
+ return gte(v, { major: 2, minor: 0, patch: 0 });
445
+ }
446
+
447
+ // src/side-question.ts
448
+ var SIDE_QUESTION_USAGE = "Usage: /btw <question>. Ask a side question about the current conversation without adding it to the main context.";
449
+ var pendingProcesses = /* @__PURE__ */ new WeakSet();
450
+ function isRecord(value) {
451
+ return value !== null && typeof value === "object" && !Array.isArray(value);
452
+ }
453
+ function parseSideQuestionContent(content) {
454
+ let text;
455
+ if (typeof content === "string") {
456
+ text = content;
457
+ } else if (Array.isArray(content)) {
458
+ const parts = [];
459
+ for (const part of content) {
460
+ if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return null;
461
+ parts.push(part.text);
462
+ }
463
+ text = parts.join("\n");
464
+ } else {
465
+ return null;
466
+ }
467
+ const match = /^\/btw(?:\s+([\s\S]*))?$/.exec(text.trim());
468
+ return match ? { question: (match[1] ?? "").trim() } : null;
469
+ }
470
+ function parseSideQuestion(prompt) {
471
+ const latest = prompt.at(-1);
472
+ return latest?.role === "user" ? parseSideQuestionContent(latest.content) : null;
473
+ }
474
+ function isSideQuestionPending(activeProcess) {
475
+ return pendingProcesses.has(activeProcess.proc);
476
+ }
477
+ function dispatchSideQuestionResponse(activeProcess, line) {
478
+ if (!pendingProcesses.has(activeProcess.proc)) return false;
479
+ let message;
480
+ try {
481
+ message = JSON.parse(line);
482
+ } catch {
483
+ return false;
484
+ }
485
+ if (!isRecord(message) || message.type !== "control_response") return false;
486
+ const response = message.response;
487
+ if (!isRecord(response) || typeof response.request_id !== "string") return false;
488
+ return activeProcess.lineEmitter.emit(`side-question:${response.request_id}`, response);
489
+ }
490
+ async function requestSideQuestion(activeProcess, question, options) {
491
+ question = question.trim();
492
+ if (!question) return { response: SIDE_QUESTION_USAGE, synthetic: true };
493
+ options.abortSignal?.throwIfAborted();
494
+ const { proc, lineEmitter } = activeProcess;
495
+ if (options.interactive || !proc.stdout) {
496
+ throw new Error("/btw requires the headless Claude Code transport; interactive sessions are not supported.");
497
+ }
498
+ if (!cliSupportsSideQuestion(options.cliVersion)) {
499
+ throw new Error("/btw requires Claude Code CLI 2.1.258 or newer (the oldest verified version).");
500
+ }
501
+ if (options.busy || lineEmitter.listenerCount("line") > 0 || pendingProcesses.has(proc)) {
502
+ throw new Error("/btw requires an idle Claude Code session. Wait for the current turn to finish.");
503
+ }
504
+ const stdin = proc.stdin;
505
+ if (proc.killed || proc.exitCode != null || proc.signalCode != null || !stdin || stdin.destroyed || stdin.writableEnded || !stdin.writable) {
506
+ throw new Error("/btw requires a live Claude Code session with writable stdin.");
507
+ }
508
+ const timeoutMs = options.timeoutMs ?? 12e4;
509
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) {
510
+ throw new Error("/btw timeoutMs must be a positive 32-bit integer.");
511
+ }
512
+ const requestId = randomUUID();
513
+ const request = JSON.stringify({
514
+ type: "control_request",
515
+ request_id: requestId,
516
+ request: {
517
+ subtype: "side_question",
518
+ question,
519
+ ...options.history === void 0 ? {} : { history: options.history }
520
+ }
521
+ });
522
+ pendingProcesses.add(proc);
523
+ return new Promise((resolve4, reject) => {
524
+ const event = `side-question:${requestId}`;
525
+ let settled = false;
526
+ let sent = false;
527
+ let cancelPending = false;
528
+ const cleanup = () => {
529
+ clearTimeout(timer);
530
+ lineEmitter.off(event, onResponse);
531
+ lineEmitter.off("close", onClose);
532
+ lineEmitter.off("error", onError);
533
+ proc.off("exit", onClose);
534
+ proc.off("close", onClose);
535
+ proc.off("error", onError);
536
+ if (!cancelPending) stdin.off("error", onError);
537
+ options.abortSignal?.removeEventListener("abort", onAbort);
538
+ pendingProcesses.delete(proc);
539
+ };
540
+ const fail = (error, cancel = false) => {
541
+ if (settled) return;
542
+ settled = true;
543
+ if (cancel && sent && !stdin.destroyed && !stdin.writableEnded && stdin.writable) {
544
+ try {
545
+ cancelPending = true;
546
+ stdin.write(
547
+ JSON.stringify({ type: "control_cancel_request", request_id: requestId }) + "\n",
548
+ () => {
549
+ queueMicrotask(() => stdin.off("error", onError));
550
+ }
551
+ );
552
+ } catch {
553
+ cancelPending = false;
554
+ }
555
+ }
556
+ cleanup();
557
+ reject(error);
558
+ };
559
+ const onClose = () => fail(new Error("Claude Code closed before answering /btw."));
560
+ const onError = (error) => fail(error);
561
+ const onAbort = () => fail(
562
+ options.abortSignal?.reason ?? new DOMException("/btw was aborted.", "AbortError"),
563
+ true
564
+ );
565
+ const onResponse = (response) => {
566
+ if (settled || response.request_id !== requestId) return;
567
+ if (response.subtype === "error") {
568
+ fail(new Error(typeof response.error === "string" ? response.error : "Claude Code rejected /btw."));
569
+ return;
570
+ }
571
+ const result = response.response;
572
+ if (response.subtype !== "success" || !isRecord(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
573
+ fail(new Error("Claude Code returned an invalid /btw response."));
574
+ return;
575
+ }
576
+ settled = true;
577
+ cleanup();
578
+ resolve4({ response: result.response, synthetic: result.synthetic });
579
+ };
580
+ const timer = setTimeout(() => {
581
+ fail(new Error(`/btw timed out after ${timeoutMs}ms.`), true);
582
+ }, timeoutMs);
583
+ lineEmitter.on(event, onResponse);
584
+ lineEmitter.on("close", onClose);
585
+ lineEmitter.on("error", onError);
586
+ proc.on("exit", onClose);
587
+ proc.on("close", onClose);
588
+ proc.on("error", onError);
589
+ stdin.on("error", onError);
590
+ options.abortSignal?.addEventListener("abort", onAbort, { once: true });
591
+ if (options.abortSignal?.aborted) {
592
+ onAbort();
593
+ return;
594
+ }
595
+ try {
596
+ sent = true;
597
+ stdin.write(request + "\n");
598
+ } catch (error) {
599
+ fail(error);
600
+ }
601
+ });
602
+ }
603
+
379
604
  // src/message-builder.ts
380
- var THINKING_KEYWORDS = {
381
- minimal: null,
382
- low: "think",
383
- medium: "think hard",
384
- high: "think harder",
385
- xhigh: "megathink",
386
- max: "ultrathink"
387
- };
388
- function reasoningKeyword(effort) {
389
- if (!effort) return null;
390
- return THINKING_KEYWORDS[effort] ?? null;
605
+ function filterSideQuestionHistory(prompt) {
606
+ let aside = false;
607
+ return prompt.filter((message) => {
608
+ if (message.role === "user") {
609
+ aside = parseSideQuestionContent(message.content) !== null;
610
+ return !aside;
611
+ }
612
+ return message.role !== "assistant" || !aside;
613
+ });
391
614
  }
392
615
  var SUPPORTED_IMAGE_TYPES = /* @__PURE__ */ new Set([
393
616
  "image/jpeg",
@@ -529,6 +752,7 @@ ${clipWithMarker(
529
752
  }
530
753
  function compactConversationHistory(prompt, opts = {}) {
531
754
  const mode = opts.mode ?? "fresh-session";
755
+ prompt = filterSideQuestionHistory(prompt);
532
756
  if (mode === "compaction") {
533
757
  return buildCompactionHistory(prompt);
534
758
  }
@@ -603,7 +827,7 @@ function buildCompactionHistory(prompt) {
603
827
  });
604
828
  return entries.join("\n\n");
605
829
  }
606
- function getClaudeUserMessage(prompt, includeHistoryContext = false, reasoningEffort, opts = {}) {
830
+ function getClaudeUserMessage(prompt, includeHistoryContext = false, opts = {}) {
607
831
  const compactionMode = opts.compactionMode === true;
608
832
  const content = [];
609
833
  if (compactionMode) {
@@ -653,6 +877,7 @@ Now continuing with the current message:
653
877
  }
654
878
  for (const msg of messages) {
655
879
  if (msg.role === "user") {
880
+ if (parseSideQuestionContent(msg.content) !== null) continue;
656
881
  if (typeof msg.content === "string") {
657
882
  const str = msg.content;
658
883
  if (str.trim()) {
@@ -708,20 +933,6 @@ Now continuing with the current message:
708
933
  }
709
934
  });
710
935
  }
711
- if (!compactionMode) {
712
- const keyword = reasoningKeyword(reasoningEffort);
713
- if (keyword) {
714
- const lastTextPart = [...content].reverse().find((p) => p.type === "text");
715
- if (lastTextPart) {
716
- lastTextPart.text = lastTextPart.text ? `${lastTextPart.text}
717
-
718
- (${keyword})` : `(${keyword})`;
719
- } else {
720
- content.push({ type: "text", text: `(${keyword})` });
721
- }
722
- log.debug("injected reasoning keyword", { effort: reasoningEffort, keyword });
723
- }
724
- }
725
936
  return JSON.stringify({
726
937
  type: "user",
727
938
  message: {
@@ -731,6 +942,10 @@ Now continuing with the current message:
731
942
  });
732
943
  }
733
944
 
945
+ // src/agent-models.ts
946
+ import { readFile, readdir } from "fs/promises";
947
+ import path from "path";
948
+
734
949
  // src/models.ts
735
950
  var PROVIDER_ID = "claude-code";
736
951
  var NPM = "@khalilgharbaoui/opencode-claude-code-plugin";
@@ -998,6 +1213,142 @@ function parseModelId(modelId) {
998
1213
  return { model: base.slice(0, -FAST_SUFFIX.length) + account, fast: true };
999
1214
  }
1000
1215
 
1216
+ // src/agent-models.ts
1217
+ var AGENT_DIR_NAMES = ["agents", "agent"];
1218
+ var REASONING_EFFORTS = [
1219
+ "minimal",
1220
+ "low",
1221
+ "medium",
1222
+ "high",
1223
+ "xhigh",
1224
+ "max"
1225
+ ];
1226
+ var registry = {};
1227
+ var defaultSubagentModel;
1228
+ function setAgentRegistry(records) {
1229
+ registry = records;
1230
+ }
1231
+ function getAgentRegistry() {
1232
+ return registry;
1233
+ }
1234
+ function setDefaultSubagentModel(model) {
1235
+ defaultSubagentModel = model?.trim() || void 0;
1236
+ }
1237
+ function getDefaultSubagentModel() {
1238
+ return defaultSubagentModel;
1239
+ }
1240
+ function accountMarker(modelId) {
1241
+ const at = modelId.indexOf("@");
1242
+ return at === -1 ? "" : modelId.slice(at);
1243
+ }
1244
+ function withoutAccountMarker(modelId) {
1245
+ const at = modelId.indexOf("@");
1246
+ return at === -1 ? modelId : modelId.slice(0, at);
1247
+ }
1248
+ function resolveAgentModel(agent, modelId, overrides) {
1249
+ if (!agent) return modelId;
1250
+ const record = (overrides?.records ?? registry)[agent];
1251
+ if (!record) return modelId;
1252
+ if (record.model?.includes("/")) return modelId;
1253
+ const fallback = overrides ? overrides.defaultSubagentModel : defaultSubagentModel;
1254
+ const declared = record.forceModel?.trim();
1255
+ const wanted = declared || (record.mode === "subagent" ? fallback : void 0);
1256
+ if (!wanted) return modelId;
1257
+ const base = withoutAccountMarker(wanted);
1258
+ if (!Object.hasOwn(defaultModels, base)) {
1259
+ log.warn("agent model override refused: unknown model", {
1260
+ agent,
1261
+ wanted: base,
1262
+ keeping: modelId
1263
+ });
1264
+ return modelId;
1265
+ }
1266
+ const resolved = `${base}${accountMarker(modelId)}`;
1267
+ if (resolved !== modelId) {
1268
+ log.debug("agent model override", { agent, from: modelId, to: resolved });
1269
+ }
1270
+ return resolved;
1271
+ }
1272
+ function resolveAgentEffort(agent, inherited, overrides) {
1273
+ if (!agent) return inherited;
1274
+ const record = (overrides?.records ?? registry)[agent];
1275
+ const declared = record?.reasoningEffort?.trim();
1276
+ if (!declared) return inherited;
1277
+ if (!REASONING_EFFORTS.includes(declared)) {
1278
+ log.warn("agent effort override refused: unknown level", {
1279
+ agent,
1280
+ wanted: declared,
1281
+ keeping: inherited
1282
+ });
1283
+ return inherited;
1284
+ }
1285
+ if (declared !== inherited) {
1286
+ log.debug("agent effort override", {
1287
+ agent,
1288
+ from: inherited,
1289
+ to: declared
1290
+ });
1291
+ }
1292
+ return declared;
1293
+ }
1294
+ function parseAgentFrontmatter(text) {
1295
+ const record = {};
1296
+ if (!text.startsWith("---")) return record;
1297
+ const lines = text.split(/\r?\n/);
1298
+ for (let i = 1; i < lines.length; i++) {
1299
+ const line = lines[i];
1300
+ if (line.trim() === "---") break;
1301
+ const match = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(line);
1302
+ if (!match) continue;
1303
+ const key = match[1];
1304
+ if (key !== "mode" && key !== "model" && key !== "forceModel" && key !== "reasoningEffort")
1305
+ continue;
1306
+ const value = match[2].trim().replace(/^["']|["']$/g, "");
1307
+ if (value) record[key] = value;
1308
+ }
1309
+ return record;
1310
+ }
1311
+ async function readAgentMarkdownRecords(directories) {
1312
+ const records = {};
1313
+ for (const directory of directories) {
1314
+ let entries;
1315
+ try {
1316
+ entries = await readdir(directory);
1317
+ } catch {
1318
+ continue;
1319
+ }
1320
+ for (const entry of entries) {
1321
+ if (!entry.endsWith(".md")) continue;
1322
+ const name = entry.slice(0, -3);
1323
+ if (records[name]) continue;
1324
+ try {
1325
+ const text = await readFile(path.join(directory, entry), "utf8");
1326
+ records[name] = parseAgentFrontmatter(text);
1327
+ } catch (err) {
1328
+ log.debug("failed to read agent markdown", {
1329
+ file: path.join(directory, entry),
1330
+ error: String(err)
1331
+ });
1332
+ }
1333
+ }
1334
+ }
1335
+ return records;
1336
+ }
1337
+ function agentDirectories(home, projectDirectory) {
1338
+ const directories = [];
1339
+ if (projectDirectory) {
1340
+ for (const name of AGENT_DIR_NAMES) {
1341
+ directories.push(path.join(projectDirectory, ".opencode", name));
1342
+ }
1343
+ }
1344
+ if (home) {
1345
+ for (const name of AGENT_DIR_NAMES) {
1346
+ directories.push(path.join(home, ".config", "opencode", name));
1347
+ }
1348
+ }
1349
+ return directories;
1350
+ }
1351
+
1001
1352
  // src/plan-mode-question.ts
1002
1353
  var QUESTION_TOOL_NAME = "question";
1003
1354
  var APPROVED_EXIT_PLAN_MODE_MESSAGE = "User has approved your plan. You can now start coding. Start with updating your todo list if applicable.";
@@ -1021,6 +1372,10 @@ function clearExitPlanModeQuestions(sessionKey2) {
1021
1372
  if (key.startsWith(prefix)) pendingQuestions.delete(key);
1022
1373
  }
1023
1374
  }
1375
+ function hasExitPlanModeQuestions(sessionKey2) {
1376
+ const prefix = `${sessionKey2}${KEY_SEPARATOR}`;
1377
+ return [...pendingQuestions.keys()].some((key) => key.startsWith(prefix));
1378
+ }
1024
1379
  function createExitPlanModeQuestionCall(sessionKey2, exitPlanModeToolUseId, plan, questionToolCallId = `exit_plan_question_${exitPlanModeToolUseId}`) {
1025
1380
  pendingQuestions.set(pendingKey(sessionKey2, questionToolCallId), exitPlanModeToolUseId);
1026
1381
  return {
@@ -1155,7 +1510,7 @@ function consumeExitPlanModeQuestionResult(sessionKey2, prompt) {
1155
1510
 
1156
1511
  // src/mcp-bridge.ts
1157
1512
  import * as fs2 from "fs";
1158
- import * as path2 from "path";
1513
+ import * as path3 from "path";
1159
1514
  import * as os2 from "os";
1160
1515
  import * as crypto from "crypto";
1161
1516
  import {
@@ -1166,8 +1521,8 @@ import {
1166
1521
  // src/tmp.ts
1167
1522
  import * as fs from "fs";
1168
1523
  import * as os from "os";
1169
- import * as path from "path";
1170
- var PLUGIN_TMP_DIR = path.join(
1524
+ import * as path2 from "path";
1525
+ var PLUGIN_TMP_DIR = path2.join(
1171
1526
  os.tmpdir(),
1172
1527
  `opencode-claude-code-${process.pid}`
1173
1528
  );
@@ -1243,14 +1598,14 @@ function deepMerge(target, source) {
1243
1598
  }
1244
1599
  function walkUp(opts) {
1245
1600
  const out = [];
1246
- let current = path2.resolve(opts.start);
1601
+ let current = path3.resolve(opts.start);
1247
1602
  while (true) {
1248
1603
  for (const target of opts.targets) {
1249
- const candidate = path2.join(current, target);
1604
+ const candidate = path3.join(current, target);
1250
1605
  if (opts.predicate(candidate)) out.push(candidate);
1251
1606
  }
1252
- if (opts.stop && current === path2.resolve(opts.stop)) break;
1253
- const parent = path2.dirname(current);
1607
+ if (opts.stop && current === path3.resolve(opts.stop)) break;
1608
+ const parent = path3.dirname(current);
1254
1609
  if (parent === current) break;
1255
1610
  current = parent;
1256
1611
  }
@@ -1258,28 +1613,28 @@ function walkUp(opts) {
1258
1613
  }
1259
1614
  function detectWorktree(cwd) {
1260
1615
  const override = process.env.OPENCODE_WORKTREE;
1261
- if (override) return path2.resolve(override);
1262
- let current = path2.resolve(cwd);
1616
+ if (override) return path3.resolve(override);
1617
+ let current = path3.resolve(cwd);
1263
1618
  while (true) {
1264
- const gitPath = path2.join(current, ".git");
1619
+ const gitPath = path3.join(current, ".git");
1265
1620
  try {
1266
1621
  if (fs2.existsSync(gitPath)) return current;
1267
1622
  } catch {
1268
1623
  }
1269
- const parent = path2.dirname(current);
1624
+ const parent = path3.dirname(current);
1270
1625
  if (parent === current) return void 0;
1271
1626
  current = parent;
1272
1627
  }
1273
1628
  }
1274
1629
  function globalConfigDir() {
1275
- const xdg = process.env.XDG_CONFIG_HOME ?? path2.join(os2.homedir(), ".config");
1276
- return path2.join(xdg, "opencode");
1630
+ const xdg = process.env.XDG_CONFIG_HOME ?? path3.join(os2.homedir(), ".config");
1631
+ return path3.join(xdg, "opencode");
1277
1632
  }
1278
1633
  function loadGlobalConfig() {
1279
1634
  const dir = globalConfigDir();
1280
1635
  let merged = {};
1281
1636
  for (const name of FILE_NAMES.slice().reverse()) {
1282
- const file = path2.join(dir, name);
1637
+ const file = path3.join(dir, name);
1283
1638
  if (!fileExists(file)) continue;
1284
1639
  const parsed = readAndParse(file);
1285
1640
  if (parsed) merged = deepMerge(merged, parsed);
@@ -1289,7 +1644,7 @@ function loadGlobalConfig() {
1289
1644
  function loadProjectFilesInDir(dir) {
1290
1645
  let merged = {};
1291
1646
  for (const name of PROJECT_FILE_NAMES) {
1292
- const file = path2.join(dir, name);
1647
+ const file = path3.join(dir, name);
1293
1648
  if (!fileExists(file)) continue;
1294
1649
  const parsed = readAndParse(file);
1295
1650
  if (parsed) merged = deepMerge(merged, parsed);
@@ -1300,7 +1655,7 @@ function dotOpencodeDirs(cwd, worktree) {
1300
1655
  const dirs = [];
1301
1656
  const seen = /* @__PURE__ */ new Set();
1302
1657
  const push = (p) => {
1303
- const abs = path2.resolve(p);
1658
+ const abs = path3.resolve(p);
1304
1659
  if (!seen.has(abs) && dirExists(abs)) {
1305
1660
  seen.add(abs);
1306
1661
  dirs.push(abs);
@@ -1316,7 +1671,7 @@ function dotOpencodeDirs(cwd, worktree) {
1316
1671
  }
1317
1672
  const home = os2.homedir();
1318
1673
  if (home) {
1319
- const homeDot = path2.join(home, ".opencode");
1674
+ const homeDot = path3.join(home, ".opencode");
1320
1675
  if (dirExists(homeDot)) push(homeDot);
1321
1676
  }
1322
1677
  const envDir = process.env.OPENCODE_CONFIG_DIR;
@@ -1441,7 +1796,7 @@ function mergeOpencodeMcp(cwd, runtimeStatus) {
1441
1796
  const projectDirs = [];
1442
1797
  const seenProjectDirs = /* @__PURE__ */ new Set();
1443
1798
  for (const f of projectFiles) {
1444
- const d = path2.dirname(f);
1799
+ const d = path3.dirname(f);
1445
1800
  if (!seenProjectDirs.has(d)) {
1446
1801
  seenProjectDirs.add(d);
1447
1802
  projectDirs.push(d);
@@ -1486,7 +1841,7 @@ function finishBridge(input) {
1486
1841
  };
1487
1842
  }
1488
1843
  const body = JSON.stringify({ mcpServers: servers }, null, 2);
1489
- const outPath = path2.join(
1844
+ const outPath = path3.join(
1490
1845
  pluginTmpDir(),
1491
1846
  `mcp-${hash}.json`
1492
1847
  );
@@ -1598,1718 +1953,1807 @@ async function fetchOpencodeToolList(provider, model, directory) {
1598
1953
  // src/session-manager.ts
1599
1954
  import { spawn } from "child_process";
1600
1955
  import { createInterface } from "readline";
1601
- import { EventEmitter } from "events";
1956
+ import { EventEmitter as EventEmitter3 } from "events";
1602
1957
  import { unlink } from "fs/promises";
1603
1958
 
1604
- // src/cli-version.ts
1605
- import { execFile } from "child_process";
1606
- import { promisify } from "util";
1607
- var execFileAsync = promisify(execFile);
1608
- var cache = /* @__PURE__ */ new Map();
1609
- function detectCliVersion(cliPath) {
1610
- const cached = cache.get(cliPath);
1611
- if (cached) return cached;
1612
- const promise = (async () => {
1613
- try {
1614
- const { stdout } = await execFileAsync(cliPath, ["--version"], {
1615
- timeout: 5e3
1616
- });
1617
- const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout.trim());
1618
- if (!match) {
1619
- log.warn("claude --version output unparseable", { stdout: stdout.trim() });
1620
- return null;
1621
- }
1622
- const v = {
1623
- major: Number(match[1]),
1624
- minor: Number(match[2]),
1625
- patch: Number(match[3]),
1626
- raw: stdout.trim()
1627
- };
1628
- log.info("detected claude cli version", { cliPath, version: v.raw });
1629
- if (!cliSupportsThinkingDisplay(v)) {
1630
- log.notice(
1631
- "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
1632
- { version: v.raw }
1633
- );
1634
- }
1635
- return v;
1636
- } catch (err) {
1637
- log.warn("failed to detect claude cli version", {
1638
- cliPath,
1639
- error: err instanceof Error ? err.message : String(err)
1640
- });
1641
- return null;
1642
- }
1643
- })();
1644
- cache.set(cliPath, promise);
1645
- return promise;
1646
- }
1647
- function gte(v, target) {
1648
- if (v.major !== target.major) return v.major > target.major;
1649
- if (v.minor !== target.minor) return v.minor > target.minor;
1650
- return v.patch >= target.patch;
1959
+ // src/proxy-broker.ts
1960
+ import { EventEmitter as EventEmitter2 } from "events";
1961
+
1962
+ // src/proxy-mcp.ts
1963
+ import { createServer } from "http";
1964
+ import * as fs3 from "fs";
1965
+ import * as path4 from "path";
1966
+ import * as crypto2 from "crypto";
1967
+ import { EventEmitter } from "events";
1968
+ var SSE_KEEPALIVE_MS = 15e3;
1969
+ function acceptsEventStream(acceptHeader) {
1970
+ return typeof acceptHeader === "string" && acceptHeader.toLowerCase().includes("text/event-stream");
1651
1971
  }
1652
- function cliSupportsThinkingDisplay(v) {
1653
- if (!v) return false;
1654
- return gte(v, { major: 2, minor: 1, patch: 142 });
1972
+ var SERVER_CLOSED_MESSAGE = "proxy MCP server closed";
1973
+ function isExpectedCleanupError(message) {
1974
+ return message.includes("timed out after") && message.includes("waiting for opencode to resolve") || message.includes("rejecting as orphaned") || message.includes("was orphaned by a new user turn") || message.includes("stream was aborted") || message.includes(SERVER_CLOSED_MESSAGE);
1655
1975
  }
1656
- function cliSupportsFastMode(v) {
1657
- if (!v) return false;
1658
- return gte(v, { major: 2, minor: 1, patch: 220 });
1976
+ var PROTOCOL_VERSION = "2024-11-05";
1977
+ var SERVER_NAME = "opencode_proxy";
1978
+ var PROXY_TOOL_PREFIX = `mcp__${SERVER_NAME}__`;
1979
+ var PROXY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
1980
+ var PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS = {
1981
+ task: 60 * 60 * 1e3,
1982
+ // 60 min
1983
+ question: 30 * 60 * 1e3
1984
+ // 30 min
1985
+ };
1986
+ var MAX_PROXY_TIMEOUT_MS = 2 ** 31 - 1;
1987
+ function resolveProxyCallTimeoutMs(toolName, input, overrides) {
1988
+ const key = toolName.toLowerCase();
1989
+ let ms = PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS[key] ?? PROXY_DEFAULT_TIMEOUT_MS;
1990
+ if (overrides) {
1991
+ const ov = lookupCaseInsensitive(overrides, key);
1992
+ if (typeof ov === "number" && ov > 0) ms = ov;
1993
+ }
1994
+ if (key === "bash") {
1995
+ const requested = input?.timeout;
1996
+ if (typeof requested === "number" && requested > ms) ms = requested;
1997
+ }
1998
+ return Math.min(ms, MAX_PROXY_TIMEOUT_MS);
1659
1999
  }
1660
- function cliSupportsThinking(v) {
1661
- if (!v) return false;
1662
- return gte(v, { major: 2, minor: 0, patch: 0 });
2000
+ function lookupCaseInsensitive(map, key) {
2001
+ if (Object.prototype.hasOwnProperty.call(map, key)) return map[key];
2002
+ for (const k of Object.keys(map)) {
2003
+ if (k.toLowerCase() === key) return map[k];
2004
+ }
2005
+ return void 0;
1663
2006
  }
1664
-
1665
- // src/session-manager.ts
1666
- var activeProcesses = /* @__PURE__ */ new Map();
1667
- var claudeSessions = /* @__PURE__ */ new Map();
1668
- var MAX_ACTIVE_PROCESSES = 16;
1669
- var PROCESS_EXIT_TIMEOUT_MS = 1500;
1670
- var PROCESS_FORCE_EXIT_TIMEOUT_MS = 500;
1671
- function envFlagEnabled(value) {
1672
- if (value === void 0) return false;
1673
- const normalized = value.trim().toLowerCase();
1674
- if (!normalized) return false;
1675
- return !["0", "false", "no", "off"].includes(normalized);
1676
- }
1677
- function isClaudeThinkingDisabled() {
1678
- return envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_THINKING) || envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING);
1679
- }
1680
- function claudeSpawnEnv(opts) {
1681
- const env = {
1682
- ...process.env,
1683
- TERM: "xterm-256color"
1684
- };
1685
- if (opts?.ignoreAnthropicApiKey) {
1686
- delete env.ANTHROPIC_API_KEY;
1687
- delete env.ANTHROPIC_AUTH_TOKEN;
2007
+ function resolveProxyClientCeilingMs(overrides) {
2008
+ let ms = PROXY_DEFAULT_TIMEOUT_MS;
2009
+ for (const v of Object.values(PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS)) {
2010
+ if (v > ms) ms = v;
1688
2011
  }
1689
- if (!isClaudeThinkingDisabled() && process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES === void 0) {
1690
- env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = "1";
2012
+ if (overrides) {
2013
+ for (const v of Object.values(overrides)) {
2014
+ if (typeof v === "number" && v > ms) ms = v;
2015
+ }
1691
2016
  }
1692
- return env;
2017
+ return Math.min(ms, MAX_PROXY_TIMEOUT_MS);
1693
2018
  }
1694
- function touch(key) {
1695
- const existing = activeProcesses.get(key);
1696
- if (existing) {
1697
- activeProcesses.delete(key);
1698
- activeProcesses.set(key, existing);
2019
+ function buildProxyTimeoutError(toolName, ms) {
2020
+ const key = toolName.toLowerCase();
2021
+ const base = `Proxy tool '${toolName}' timed out after ${ms}ms waiting for opencode to resolve the call`;
2022
+ if (key === "task") {
2023
+ return new Error(
2024
+ base + " (the subagent). The subagent may still be running but its result is no longer reachable in this session. Do not declare the dispatch failed, and do not 'schedule a wake-up' or defer -- that mechanism does not apply here. If the result is required, re-dispatch or verify it directly now."
2025
+ );
1699
2026
  }
2027
+ return new Error(base);
1700
2028
  }
1701
- function evictIfNeeded() {
1702
- while (activeProcesses.size >= MAX_ACTIVE_PROCESSES) {
1703
- const oldestKey = activeProcesses.keys().next().value;
1704
- if (!oldestKey) break;
1705
- log.info("evicting LRU claude process", { sessionKey: oldestKey });
1706
- deleteActiveProcess(oldestKey);
2029
+ var TASK_PROXY_NOTE = "This is the ONLY tool that dispatches opencode subagents (including user @-mentions). Claude Code's built-in TaskCreate/TaskUpdate manage a local todo list and cannot dispatch subagents. Do not search config files to verify a subagent type exists \u2014 invalid types fail fast with a clear error. Foreground calls block until the subagent finishes; set `background` to request opencode's background execution mode. Task calls get a 60-minute proxy deadline by default (configurable via proxyToolTimeoutMs).";
2030
+ var AGENT_TYPES_HEADING = "Available agent types";
2031
+ var AGENT_BLURB_LIMIT = 140;
2032
+ var QUESTION_PROXY_NOTE = "This routes structured questions through opencode's native `question` tool, which renders a TUI form with the options you provide and blocks until the operator answers. Claude Code's built-in AskUserQuestion is disabled in this environment; this proxy is the ONLY way to ask the operator for a decision or clarification. Answers come back as arrays of selected labels (set `multiple: true` to allow more than one). If the operator dismisses the form the call returns an error \u2014 treat that as 'no answer' and stop, do not guess. Question calls get a 30-minute proxy deadline by default (configurable via proxyToolTimeoutMs); for long-AFK scenarios prefer fewer, high-signal questions.";
2033
+ var COMPRESS_PROXY_NOTE = "The current turn continues normally after this call \u2014 finish what you are doing. The reset happens at the START of the next turn: the Claude Code session is discarded and a fresh one begins with your summary as its only prior context. Everything else, including tool output and files you read, is gone, so write the summary as the authoritative record. Call this once per compression, when older resolved work no longer needs full detail.";
2034
+ function extractAgentTypeList(liveDescription) {
2035
+ const live = liveDescription?.trim();
2036
+ if (!live) return void 0;
2037
+ const start = live.indexOf(AGENT_TYPES_HEADING);
2038
+ if (start === -1) return void 0;
2039
+ const entries = [];
2040
+ for (const raw of live.slice(start).split("\n")) {
2041
+ const match = /^-\s*([^:]+):\s*(.+)$/.exec(raw.trim());
2042
+ if (!match) continue;
2043
+ const name = match[1].trim();
2044
+ const blurb = match[2].trim();
2045
+ entries.push(
2046
+ `- ${name}: ${blurb.length > AGENT_BLURB_LIMIT ? `${blurb.slice(0, AGENT_BLURB_LIMIT).trimEnd()}\u2026` : blurb}`
2047
+ );
1707
2048
  }
2049
+ if (entries.length === 0) return void 0;
2050
+ return `Valid subagent_type values, from opencode's live registry \u2014 anything else fails:
2051
+ ${entries.join("\n")}`;
1708
2052
  }
1709
- function getActiveProcess(key) {
1710
- const ap = activeProcesses.get(key);
1711
- if (ap) touch(key);
1712
- return ap;
1713
- }
1714
- function setActiveProcess(key, ap) {
1715
- activeProcesses.set(key, ap);
1716
- }
1717
- function detachActiveProcess(key) {
1718
- const ap = activeProcesses.get(key);
1719
- if (!ap) return void 0;
1720
- activeProcesses.delete(key);
1721
- void ap.proxyServer?.close();
1722
- return ap;
1723
- }
1724
- function deleteActiveProcess(key) {
1725
- const ap = detachActiveProcess(key);
1726
- ap?.proc.kill();
1727
- }
1728
- function hasProcessExited(proc) {
1729
- return proc.exitCode !== null || proc.signalCode !== null;
1730
- }
1731
- function waitForProcessExit(proc, timeoutMs) {
1732
- if (hasProcessExited(proc)) return Promise.resolve(true);
1733
- return new Promise((resolve4) => {
1734
- const onExit = () => {
1735
- clearTimeout(timer);
1736
- resolve4(true);
1737
- };
1738
- const timer = setTimeout(() => {
1739
- proc.off("exit", onExit);
1740
- resolve4(hasProcessExited(proc));
1741
- }, timeoutMs);
1742
- proc.once("exit", onExit);
1743
- });
1744
- }
1745
- async function deleteActiveProcessAndWait(key, options = {}) {
1746
- const ap = detachActiveProcess(key);
1747
- if (!ap || hasProcessExited(ap.proc)) return true;
1748
- const gracefulExit = waitForProcessExit(
1749
- ap.proc,
1750
- options.exitTimeoutMs ?? PROCESS_EXIT_TIMEOUT_MS
1751
- );
1752
- ap.proc.kill();
1753
- if (await gracefulExit) return true;
1754
- const forcedExit = waitForProcessExit(
1755
- ap.proc,
1756
- options.forceExitTimeoutMs ?? PROCESS_FORCE_EXIT_TIMEOUT_MS
2053
+ function overlayTaskProxyDescription(tools, liveDescription) {
2054
+ const agentTypes = extractAgentTypeList(liveDescription);
2055
+ if (!agentTypes) return tools;
2056
+ return tools.map(
2057
+ (t) => t.name === "task" ? { ...t, description: `${agentTypes}
2058
+
2059
+ ${t.description}` } : t
1757
2060
  );
1758
- ap.proc.kill("SIGKILL");
1759
- if (await forcedExit) return true;
1760
- log.warn("claude process did not exit; starting a fresh session", {
1761
- sessionKey: key
1762
- });
1763
- deleteClaudeSessionId(key);
1764
- return false;
1765
- }
1766
- function getClaudeSessionId(key) {
1767
- return claudeSessions.get(key);
1768
2061
  }
1769
- function setClaudeSessionId(key, sessionId) {
1770
- claudeSessions.set(key, sessionId);
2062
+ function overlayQuestionProxyDescription(tools, liveDescription) {
2063
+ const live = liveDescription?.trim();
2064
+ if (!live) return tools;
2065
+ return tools.map(
2066
+ (t) => t.name === "question" ? { ...t, description: `${live}
2067
+
2068
+ ${QUESTION_PROXY_NOTE}` } : t
2069
+ );
1771
2070
  }
1772
- function deleteClaudeSessionId(key) {
1773
- clearExitPlanModeQuestions(key);
1774
- const claudeSessionId = claudeSessions.get(key);
1775
- if (claudeSessionId) clearLedger(claudeSessionId);
1776
- claudeSessions.delete(key);
2071
+ function filterQuestionProxyByOpencodeSupport(tools, opencodeHasQuestion) {
2072
+ if (opencodeHasQuestion) return tools;
2073
+ return tools.filter((t) => t.name !== "question");
1777
2074
  }
1778
- function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey) {
1779
- evictIfNeeded();
1780
- log.info("spawning new claude process", { cliPath, cliArgs, cwd, sessionKey: sessionKey2 });
1781
- const proc = spawn(cliPath, cliArgs, {
1782
- cwd,
1783
- stdio: ["pipe", "pipe", "pipe"],
1784
- env: claudeSpawnEnv({ ignoreAnthropicApiKey }),
1785
- shell: process.platform === "win32"
1786
- });
1787
- const lineEmitter = new EventEmitter();
1788
- const rl = createInterface({ input: proc.stdout });
1789
- rl.on("line", (line) => {
1790
- lineEmitter.emit("line", line);
1791
- });
1792
- rl.on("close", () => {
1793
- lineEmitter.emit("close");
1794
- });
1795
- const ap = {
1796
- proc,
1797
- lineEmitter,
1798
- proxyServer: proxyServer ?? null,
1799
- mcpHash,
1800
- systemPromptFile
1801
- };
1802
- activeProcesses.set(sessionKey2, ap);
1803
- proc.on("error", (err) => {
1804
- log.error("claude process error", { sessionKey: sessionKey2, error: err.message });
1805
- });
1806
- proc.on("exit", (code, signal) => {
1807
- log.info("claude process exited", { code, signal, sessionKey: sessionKey2 });
1808
- void proxyServer?.close();
1809
- if (systemPromptFile) {
1810
- void unlink(systemPromptFile).catch(() => {
1811
- });
1812
- }
1813
- const ownsSessionKey = activeProcesses.get(sessionKey2) === ap;
1814
- if (ownsSessionKey) activeProcesses.delete(sessionKey2);
1815
- if (ownsSessionKey && code !== 0 && code !== null) {
1816
- log.info("process exited with error, clearing session", {
1817
- code,
1818
- sessionKey: sessionKey2
1819
- });
1820
- claudeSessions.delete(sessionKey2);
2075
+ var DEFAULT_PROXY_TOOLS = [
2076
+ {
2077
+ name: "bash",
2078
+ description: "Execute a shell command. Routed through opencode's bash tool so permission prompts flow through opencode's UI.",
2079
+ inputSchema: {
2080
+ type: "object",
2081
+ properties: {
2082
+ command: {
2083
+ type: "string",
2084
+ description: "The shell command to execute."
2085
+ },
2086
+ description: {
2087
+ type: "string",
2088
+ description: "Short human-readable description of what the command does."
2089
+ },
2090
+ timeout: {
2091
+ type: "number",
2092
+ description: "Optional timeout in milliseconds."
2093
+ }
2094
+ },
2095
+ required: ["command"]
1821
2096
  }
1822
- });
1823
- proc.stderr?.on("data", (data) => {
1824
- const stderr = data.toString();
1825
- log.debug("stderr", { data: stderr.slice(0, 200) });
1826
- if (stderr.includes("No conversation found") || stderr.includes("Session ID") && (stderr.includes("already in use") || stderr.includes("not found") || stderr.includes("invalid"))) {
1827
- if (activeProcesses.get(sessionKey2) === ap) {
1828
- log.warn("claude session ID error, clearing session", {
1829
- sessionKey: sessionKey2,
1830
- error: stderr.slice(0, 200)
1831
- });
1832
- claudeSessions.delete(sessionKey2);
1833
- } else {
1834
- log.debug("ignoring session ID error from stale claude process", {
1835
- sessionKey: sessionKey2
1836
- });
1837
- }
2097
+ },
2098
+ {
2099
+ name: "write",
2100
+ description: "Write a file. Routed through opencode's write tool so permission prompts flow through opencode's UI.",
2101
+ inputSchema: {
2102
+ type: "object",
2103
+ properties: {
2104
+ filePath: {
2105
+ type: "string",
2106
+ description: "The file to write. Absolute paths are preferred."
2107
+ },
2108
+ content: {
2109
+ type: "string",
2110
+ description: "The full content to write to the file."
2111
+ }
2112
+ },
2113
+ required: ["filePath", "content"]
1838
2114
  }
1839
- });
1840
- return ap;
1841
- }
1842
- function appendResumeIfNeeded(sessionKey2, cliArgs) {
1843
- if (cliArgs.includes("--resume") || cliArgs.includes("--session-id")) {
1844
- return cliArgs;
1845
- }
1846
- const sid = claudeSessions.get(sessionKey2);
1847
- if (!sid) return cliArgs;
1848
- return [...cliArgs, "--resume", sid];
1849
- }
1850
- function respawnActiveProcess(sessionKey2, cliPath, cliArgs, cwd, ignoreAnthropicApiKey) {
1851
- const old = activeProcesses.get(sessionKey2);
1852
- if (!old) return void 0;
1853
- activeProcesses.delete(sessionKey2);
1854
- old.proc.removeAllListeners("exit");
1855
- try {
1856
- old.proc.kill();
1857
- } catch {
1858
- }
1859
- return spawnClaudeProcess(
1860
- cliPath,
1861
- appendResumeIfNeeded(sessionKey2, cliArgs),
1862
- cwd,
1863
- sessionKey2,
1864
- old.proxyServer,
1865
- old.mcpHash,
1866
- old.systemPromptFile,
1867
- ignoreAnthropicApiKey
1868
- );
1869
- }
1870
- function buildCliArgs(opts) {
1871
- const {
1872
- sessionKey: sessionKey2,
1873
- skipPermissions,
1874
- includeSessionId = true,
1875
- model,
1876
- permissionMode,
1877
- mcpConfig,
1878
- strictMcpConfig,
1879
- disallowedTools,
1880
- appendSystemPromptFile,
1881
- thinking,
1882
- thinkingDisplay,
1883
- fastMode,
1884
- cliVersion
1885
- } = opts;
1886
- const args = [
1887
- "--print",
1888
- "--output-format",
1889
- "stream-json",
1890
- "--input-format",
1891
- "stream-json",
1892
- "--include-partial-messages",
1893
- "--verbose"
1894
- ];
1895
- if (model) {
1896
- args.push("--model", model);
1897
- }
1898
- if (permissionMode) {
1899
- args.push("--permission-mode", permissionMode);
1900
- }
1901
- if (includeSessionId) {
1902
- const sessionId = claudeSessions.get(sessionKey2);
1903
- if (sessionId && !activeProcesses.has(sessionKey2)) {
1904
- args.push("--resume", sessionId);
2115
+ },
2116
+ {
2117
+ name: "edit",
2118
+ description: "Replace text in an existing file. Routed through opencode's edit tool so permission prompts flow through opencode's UI.",
2119
+ inputSchema: {
2120
+ type: "object",
2121
+ properties: {
2122
+ filePath: {
2123
+ type: "string",
2124
+ description: "The file to edit. Absolute paths are preferred."
2125
+ },
2126
+ oldString: {
2127
+ type: "string",
2128
+ description: "The exact text to replace."
2129
+ },
2130
+ newString: {
2131
+ type: "string",
2132
+ description: "The replacement text."
2133
+ },
2134
+ replaceAll: {
2135
+ type: "boolean",
2136
+ description: "Replace all occurrences instead of just the first one."
2137
+ }
2138
+ },
2139
+ required: ["filePath", "oldString", "newString"]
1905
2140
  }
1906
- }
1907
- if (mcpConfig) {
1908
- const configs = Array.isArray(mcpConfig) ? mcpConfig : [mcpConfig];
1909
- const filtered = configs.filter((c) => typeof c === "string" && c.length > 0);
1910
- if (filtered.length > 0) {
1911
- args.push("--mcp-config", ...filtered);
2141
+ },
2142
+ {
2143
+ name: "webfetch",
2144
+ description: "Fetch content from a URL. Routed through opencode's webfetch tool so permission prompts flow through opencode's UI. Returns the page content in the requested format.",
2145
+ inputSchema: {
2146
+ type: "object",
2147
+ properties: {
2148
+ url: {
2149
+ type: "string",
2150
+ description: "The URL to fetch content from. Must start with http:// or https://."
2151
+ },
2152
+ format: {
2153
+ type: "string",
2154
+ enum: ["text", "markdown", "html"],
2155
+ description: "The format to return the content in. Defaults to markdown."
2156
+ },
2157
+ timeout: {
2158
+ type: "number",
2159
+ description: "Optional timeout in seconds (max 120)."
2160
+ }
2161
+ },
2162
+ required: ["url"]
2163
+ }
2164
+ },
2165
+ {
2166
+ name: "task",
2167
+ description: "Launch an opencode subagent to handle a complex multi-step task autonomously. Routed through opencode's task tool so subagent orchestration, permission, and lifecycle are handled by opencode. Use `subagent_type` to pick which configured subagent runs (e.g. `build`, `general`, `explore`, or any custom subagent declared in opencode.json). " + TASK_PROXY_NOTE,
2168
+ inputSchema: {
2169
+ type: "object",
2170
+ properties: {
2171
+ description: {
2172
+ type: "string",
2173
+ description: "A short (3-5 words) description of the task"
2174
+ },
2175
+ prompt: {
2176
+ type: "string",
2177
+ description: "The task for the agent to perform"
2178
+ },
2179
+ subagent_type: {
2180
+ type: "string",
2181
+ description: "The type of specialized agent to use for this task"
2182
+ },
2183
+ task_id: {
2184
+ type: "string",
2185
+ description: "Set this only if you mean to resume a previous task \u2014 pass the prior task_id to continue the same subagent session instead of creating a fresh one."
2186
+ },
2187
+ command: {
2188
+ type: "string",
2189
+ description: "The command that triggered this task"
2190
+ },
2191
+ background: {
2192
+ type: "boolean",
2193
+ description: "Run the task in the background when supported by opencode"
2194
+ }
2195
+ },
2196
+ required: ["description", "prompt", "subagent_type"]
2197
+ }
2198
+ },
2199
+ {
2200
+ name: "question",
2201
+ description: "Ask the operator structured questions with options and receive their answers back. Routed through opencode's native `question` tool so the prompt renders as a real TUI form (with options and a custom-answer field) instead of a plain text turn. Use this when you need a decision, clarification, or preference from the operator mid-task. " + QUESTION_PROXY_NOTE,
2202
+ inputSchema: {
2203
+ type: "object",
2204
+ properties: {
2205
+ questions: {
2206
+ type: "array",
2207
+ description: "Questions to ask.",
2208
+ items: {
2209
+ type: "object",
2210
+ properties: {
2211
+ question: {
2212
+ type: "string",
2213
+ description: "Complete question."
2214
+ },
2215
+ header: {
2216
+ type: "string",
2217
+ description: "Very short label (max 30 chars)."
2218
+ },
2219
+ options: {
2220
+ type: "array",
2221
+ description: "Available choices.",
2222
+ items: {
2223
+ type: "object",
2224
+ properties: {
2225
+ label: {
2226
+ type: "string",
2227
+ description: "Display text (1-5 words, concise)."
2228
+ },
2229
+ description: {
2230
+ type: "string",
2231
+ description: "Explanation of choice."
2232
+ }
2233
+ },
2234
+ required: ["label", "description"]
2235
+ }
2236
+ },
2237
+ multiple: {
2238
+ type: "boolean",
2239
+ description: "Allow selecting multiple choices. Defaults to false."
2240
+ }
2241
+ },
2242
+ required: ["question", "header", "options"]
2243
+ }
2244
+ }
2245
+ },
2246
+ required: ["questions"]
2247
+ }
2248
+ },
2249
+ {
2250
+ name: "compress",
2251
+ description: "Replace older conversation detail with a summary you write, then continue in a fresh Claude Code session. Handled inside the plugin, so it never prompts the operator. " + COMPRESS_PROXY_NOTE,
2252
+ inputSchema: {
2253
+ type: "object",
2254
+ properties: {
2255
+ summary: {
2256
+ type: "string",
2257
+ description: "Dense technical summary of the work being compressed: decisions made, files changed, commands run and their outcomes, and what is still open. This is the ONLY prior context that survives, so anything omitted is lost."
2258
+ }
2259
+ },
2260
+ required: ["summary"]
1912
2261
  }
1913
2262
  }
1914
- if (strictMcpConfig) {
1915
- args.push("--strict-mcp-config");
1916
- }
1917
- if (disallowedTools && disallowedTools.length > 0) {
1918
- args.push("--disallowedTools", ...disallowedTools);
1919
- }
1920
- if (thinking && cliSupportsThinking(cliVersion ?? null)) {
1921
- args.push("--thinking", thinking);
1922
- }
1923
- if (thinkingDisplay && cliSupportsThinkingDisplay(cliVersion ?? null)) {
1924
- args.push("--thinking-display", thinkingDisplay);
1925
- }
1926
- if (appendSystemPromptFile) {
1927
- args.push("--append-system-prompt-file", appendSystemPromptFile);
1928
- }
1929
- if (fastMode && cliSupportsFastMode(cliVersion ?? null)) {
1930
- args.push("--settings", JSON.stringify({ fastMode: true }));
2263
+ ];
2264
+ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides, interceptors) {
2265
+ const calls = new EventEmitter();
2266
+ const pending = /* @__PURE__ */ new Map();
2267
+ const authToken = crypto2.randomBytes(32).toString("hex");
2268
+ const expectedAuth = Buffer.from(`Bearer ${authToken}`);
2269
+ let boundAuthority = "";
2270
+ function authOk(req) {
2271
+ const got = req.headers.authorization;
2272
+ if (typeof got !== "string") return false;
2273
+ const candidate = Buffer.from(got);
2274
+ if (candidate.length !== expectedAuth.length) return false;
2275
+ return crypto2.timingSafeEqual(candidate, expectedAuth);
1931
2276
  }
1932
- if (skipPermissions) {
1933
- args.push("--dangerously-skip-permissions");
2277
+ function reject(req, res, statusCode, reason) {
2278
+ log.notice("proxy-mcp rejected a request", {
2279
+ statusCode,
2280
+ reason,
2281
+ method: req.method,
2282
+ hasAuthorization: typeof req.headers.authorization === "string"
2283
+ });
2284
+ res.statusCode = statusCode;
2285
+ res.setHeader("Connection", "close");
2286
+ res.on("finish", () => {
2287
+ req.socket?.destroy();
2288
+ });
2289
+ res.end();
1934
2290
  }
1935
- return args;
1936
- }
1937
- function sessionKey(cwd, modelId) {
1938
- return `${cwd}::${modelId}`;
1939
- }
1940
-
1941
- // src/claude-session-wrapper.ts
1942
- import { EventEmitter as EventEmitter2 } from "events";
1943
- import { unlink as unlink2 } from "fs/promises";
1944
-
1945
- // src/claude-session-bun.ts
1946
- import * as os3 from "os";
1947
- import * as fs3 from "fs";
1948
- import * as path3 from "path";
1949
- import { execFileSync } from "child_process";
1950
- import { randomUUID } from "crypto";
1951
- function resolveClaude(cmd = "claude") {
1952
- if (path3.isAbsolute(cmd) && fs3.existsSync(cmd)) return cmd;
1953
- const viaBun = Bun.which(cmd);
1954
- if (viaBun) return viaBun;
1955
- const isWin = os3.platform() === "win32";
1956
- try {
1957
- const out = execFileSync(isWin ? "where" : "which", [cmd], {
1958
- encoding: "utf8",
1959
- stdio: ["ignore", "pipe", "ignore"]
1960
- });
1961
- const first = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).find((p) => fs3.existsSync(p));
1962
- if (first) return first;
1963
- } catch {
1964
- }
1965
- throw new Error(`Could not resolve command on PATH: ${cmd}`);
1966
- }
1967
- function encodeCwd(cwd) {
1968
- return path3.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
1969
- }
1970
- var TERMINAL_STOP = /* @__PURE__ */ new Set(["end_turn", "stop_sequence", "max_tokens"]);
1971
- var delay = (ms) => new Promise((r) => setTimeout(r, ms));
1972
- function resolveConfigDir(configDir) {
1973
- const value = configDir ?? process.env.CLAUDE_CONFIG_DIR;
1974
- if (!value) return path3.join(os3.homedir(), ".claude");
1975
- if (value === "~") return os3.homedir();
1976
- if (value.startsWith("~/") || value.startsWith("~\\")) {
1977
- return path3.join(os3.homedir(), value.slice(2));
1978
- }
1979
- return path3.resolve(value);
1980
- }
1981
- var ClaudeSession = class {
1982
- sessionId;
1983
- cwd;
1984
- configDir;
1985
- jsonlPath;
1986
- raw = "";
1987
- proc = null;
1988
- cursor = 0;
1989
- // index into transcript split('\n')
1990
- lastDataAt = 0;
1991
- exited = false;
1992
- exitCode = null;
1993
- aborted = false;
1994
- signal;
1995
- o;
1996
- constructor(opts = {}) {
1997
- this.cwd = path3.resolve(opts.cwd ?? process.cwd());
1998
- this.configDir = resolveConfigDir(opts.configDir);
1999
- this.signal = opts.signal;
2000
- this.sessionId = randomUUID();
2001
- this.jsonlPath = path3.join(
2002
- this.configDir,
2003
- "projects",
2004
- encodeCwd(this.cwd),
2005
- `${this.sessionId}.jsonl`
2006
- );
2007
- this.o = {
2008
- cwd: this.cwd,
2009
- cliPath: opts.cliPath,
2010
- configDir: this.configDir,
2011
- model: opts.model,
2012
- settingSources: opts.settingSources,
2013
- extraArgs: opts.extraArgs ?? [],
2014
- ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey,
2015
- cols: opts.cols ?? 200,
2016
- rows: opts.rows ?? 50,
2017
- bootMinMs: opts.bootMinMs ?? 3e3,
2018
- bootQuietMs: opts.bootQuietMs ?? 1500,
2019
- bootMaxMs: opts.bootMaxMs ?? 25e3,
2020
- pollMs: opts.pollMs ?? 250,
2021
- // Agentic turns (tool loops) routinely run for many minutes; a short
2022
- // cap would surface as a mid-task error result. 30 min mirrors the
2023
- // proxy-tool ceiling rather than a chat-reply expectation.
2024
- turnTimeoutMs: opts.turnTimeoutMs ?? 18e5,
2025
- bracketedPaste: opts.bracketedPaste ?? true,
2026
- submitMinMs: opts.submitMinMs ?? 200,
2027
- submitConfirmMs: opts.submitConfirmMs ?? 1500,
2028
- submitMaxRetries: opts.submitMaxRetries ?? 8,
2029
- debug: opts.debug ?? false
2030
- };
2031
- }
2032
- async start() {
2033
- if (this.signal?.aborted) throw new Error("aborted before start");
2034
- this.signal?.addEventListener(
2035
- "abort",
2036
- () => {
2037
- this.aborted = true;
2038
- this.dispose();
2039
- },
2040
- { once: true }
2041
- );
2042
- const claude = resolveClaude(this.o.cliPath ?? "claude");
2043
- const args = ["--session-id", this.sessionId];
2044
- if (this.o.model) args.push("--model", this.o.model);
2045
- if (this.o.settingSources !== null && this.o.settingSources !== void 0) {
2046
- args.push("--setting-sources", this.o.settingSources);
2047
- }
2048
- if (this.o.extraArgs && this.o.extraArgs.length) args.push(...this.o.extraArgs);
2049
- if (this.o.debug)
2050
- process.stderr.write(`[session] spawn: ${claude} ${args.join(" ")}
2051
- `);
2052
- this.lastDataAt = Date.now();
2053
- this.proc = Bun.spawn([claude, ...args], {
2054
- cwd: this.cwd,
2055
- env: {
2056
- ...process.env,
2057
- CLAUDE_CONFIG_DIR: this.o.configDir,
2058
- TERM: "xterm-256color",
2059
- ...this.o.ignoreAnthropicApiKey ? { ANTHROPIC_API_KEY: void 0, ANTHROPIC_AUTH_TOKEN: void 0 } : {}
2060
- },
2061
- terminal: {
2062
- cols: this.o.cols,
2063
- rows: this.o.rows,
2064
- data: (_term, d) => {
2065
- this.lastDataAt = Date.now();
2066
- const chunk = Buffer.from(d).toString("utf8");
2067
- this.raw += chunk;
2068
- if (this.o.debug) process.stdout.write(chunk);
2069
- }
2070
- }
2071
- });
2072
- this.proc.exited.then((code) => {
2073
- this.exitCode = typeof code === "number" ? code : null;
2074
- this.exited = true;
2075
- this.proc = null;
2076
- }).catch(() => {
2077
- this.exited = true;
2078
- this.proc = null;
2079
- });
2080
- await this.waitForBoot();
2081
- this.cursor = this.lineCount();
2082
- }
2083
- /** Wait until the TUI has been quiet for bootQuietMs (Ink ready), bounded by
2084
- * bootMinMs..bootMaxMs. */
2085
- async waitForBoot() {
2086
- const start = Date.now();
2087
- while (Date.now() - start < this.o.bootMaxMs) {
2088
- await delay(150);
2089
- if (this.aborted) throw new Error("aborted during boot");
2090
- if (this.exited) {
2091
- throw new Error(this.failureMessage("claude exited during boot", true));
2092
- }
2093
- const elapsed = Date.now() - start;
2094
- const sinceData = Date.now() - this.lastDataAt;
2095
- if (elapsed >= this.o.bootMinMs && sinceData >= this.o.bootQuietMs) return;
2291
+ const server2 = createServer(async (req, res) => {
2292
+ if (req.method !== "POST" || !req.url?.startsWith("/mcp")) {
2293
+ reject(req, res, 404, "not a POST to /mcp");
2294
+ return;
2096
2295
  }
2097
- }
2098
- /** Submit the freshly-injected prompt and confirm the turn was actually
2099
- * accepted. A large bracketed paste collapses into a "[Pasted text]"
2100
- * placeholder; an Enter sent while claude is still ingesting the paste is
2101
- * silently dropped, so a single fixed-delay Enter races the paste and can
2102
- * leave the prompt sitting unsubmitted (→ hang until turnTimeoutMs). Send
2103
- * Enter, then poll for transcript growth past the cursor (the turn's records
2104
- * are written on acceptance); resend Enter until accepted or the retry
2105
- * budget is spent. Polling growth (not a blind delay) also stops us from
2106
- * sending a stray Enter once the turn is in flight. */
2107
- async submitTurn() {
2108
- await delay(this.o.submitMinMs);
2109
- for (let attempt = 0; attempt < this.o.submitMaxRetries; attempt++) {
2110
- if (this.aborted || this.exited || !this.proc) return;
2111
- this.proc.terminal.write("\r");
2112
- const until = Date.now() + this.o.submitConfirmMs;
2113
- while (Date.now() < until) {
2114
- await delay(80);
2115
- if (this.aborted || this.exited) return;
2116
- if (this.lineCount() > this.cursor) return;
2117
- }
2296
+ if (req.headers.host !== boundAuthority) {
2297
+ reject(req, res, 403, "host header is not the bound authority");
2298
+ return;
2118
2299
  }
2119
- }
2120
- readRawLines() {
2121
- try {
2122
- return fs3.readFileSync(this.jsonlPath, "utf8").split("\n");
2123
- } catch {
2124
- return [];
2300
+ if (req.headers.origin !== void 0) {
2301
+ reject(req, res, 403, "origin header present");
2302
+ return;
2125
2303
  }
2126
- }
2127
- /** Count of complete lines (split('\n') minus the trailing/partial element). */
2128
- lineCount() {
2129
- const lines = this.readRawLines();
2130
- return lines.length > 0 ? lines.length - 1 : 0;
2131
- }
2132
- rawTail(max = 600) {
2133
- const clean = this.raw.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").replace(/\s+/g, " ").trim();
2134
- return clean.length > max ? clean.slice(-max) : clean;
2135
- }
2136
- failureMessage(reason, includeRaw = false) {
2137
- const parts = [
2138
- `${reason} (sessionId=${this.sessionId}, jsonlPath=${this.jsonlPath}, exitCode=${this.exitCode ?? "unknown"})`
2139
- ];
2140
- if (includeRaw) {
2141
- const tail = this.rawTail();
2142
- if (tail) parts.push(`terminalTail=${JSON.stringify(tail)}`);
2304
+ const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
2305
+ if (contentType !== "application/json") {
2306
+ reject(req, res, 415, "content-type is not application/json");
2307
+ return;
2143
2308
  }
2144
- return parts.join("; ");
2145
- }
2146
- /**
2147
- * Inject a turn into the live session and return the assistant reply once a
2148
- * terminal stop_reason is observed in the transcript.
2149
- */
2150
- async ask(prompt, perTurnTimeoutMs) {
2151
- if (this.aborted) throw new Error("aborted");
2152
- if (!this.proc || this.exited)
2153
- throw new Error("session not started or already exited");
2154
- const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
2155
- const t0 = Date.now();
2156
- if (this.o.bracketedPaste) {
2157
- this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
2158
- } else {
2159
- this.proc.terminal.write(prompt);
2309
+ if (!authOk(req)) {
2310
+ reject(req, res, 401, "missing or invalid bearer token");
2311
+ return;
2160
2312
  }
2161
- await this.submitTurn();
2162
- const collected = [];
2163
- let lastUsage = null;
2164
- let stopReason = null;
2165
- const deadline = Date.now() + timeout;
2166
- while (Date.now() < deadline) {
2167
- await delay(this.o.pollMs);
2168
- if (this.aborted) throw new Error("aborted mid-turn");
2169
- const lines = this.readRawLines();
2170
- const lastComplete = lines.length - 1;
2171
- if (lastComplete <= this.cursor) {
2172
- if (this.exited) throw new Error(this.failureMessage("claude exited mid-turn", true));
2173
- continue;
2313
+ let requestId = null;
2314
+ let requestMethod = null;
2315
+ let sse = null;
2316
+ try {
2317
+ const body = await readBody(req);
2318
+ const request = JSON.parse(body);
2319
+ requestId = request?.id ?? null;
2320
+ requestMethod = typeof request?.method === "string" ? request.method : null;
2321
+ if (request?.jsonrpc !== "2.0" || typeof request.method !== "string") {
2322
+ writeJson(res, {
2323
+ jsonrpc: "2.0",
2324
+ id: requestId,
2325
+ error: { code: -32600, message: "Invalid request" }
2326
+ });
2327
+ return;
2174
2328
  }
2175
- for (let i = this.cursor; i < lastComplete; i++) {
2176
- const s = lines[i];
2177
- if (!s || !s.trim()) continue;
2178
- let rec;
2179
- try {
2180
- rec = JSON.parse(s);
2181
- } catch {
2182
- continue;
2183
- }
2184
- if (rec.type === "assistant" && rec.message) {
2185
- for (const b of rec.message.content ?? []) {
2186
- if (b?.type === "text" && typeof b.text === "string")
2187
- collected.push(b.text);
2329
+ log.debug("proxy-mcp request", {
2330
+ method: request.method,
2331
+ id: request.id
2332
+ });
2333
+ if (request.method === "initialize") {
2334
+ writeJson(res, {
2335
+ jsonrpc: "2.0",
2336
+ id: requestId,
2337
+ result: {
2338
+ protocolVersion: PROTOCOL_VERSION,
2339
+ capabilities: { tools: {} },
2340
+ serverInfo: {
2341
+ name: SERVER_NAME,
2342
+ version: "0.1.0"
2343
+ }
2188
2344
  }
2189
- if (rec.message.usage) lastUsage = rec.message.usage;
2190
- if (rec.message.stop_reason && TERMINAL_STOP.has(rec.message.stop_reason)) {
2191
- stopReason = rec.message.stop_reason;
2345
+ });
2346
+ return;
2347
+ }
2348
+ if (request.method === "notifications/initialized") {
2349
+ res.statusCode = 204;
2350
+ res.end();
2351
+ return;
2352
+ }
2353
+ if (request.method === "tools/list") {
2354
+ writeJson(res, {
2355
+ jsonrpc: "2.0",
2356
+ id: requestId,
2357
+ result: {
2358
+ tools: tools.map((t) => ({
2359
+ name: t.name,
2360
+ description: t.description,
2361
+ inputSchema: t.inputSchema
2362
+ }))
2192
2363
  }
2193
- }
2364
+ });
2365
+ return;
2194
2366
  }
2195
- this.cursor = lastComplete;
2196
- if (stopReason) break;
2197
- }
2198
- if (!stopReason) {
2199
- throw new Error(
2200
- this.failureMessage(
2201
- `turn timed out after ${timeout}ms (no terminal assistant record; collected ${collected.length} text block(s))`
2202
- )
2203
- );
2204
- }
2205
- const u = lastUsage ?? {};
2206
- return {
2207
- text: collected.join("\n").trim(),
2208
- stopReason,
2209
- usage: lastUsage,
2210
- cacheReadTokens: u.cache_read_input_tokens ?? 0,
2211
- cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
2212
- ephemeral1hTokens: u.cache_creation?.ephemeral_1h_input_tokens ?? 0,
2213
- ephemeral5mTokens: u.cache_creation?.ephemeral_5m_input_tokens ?? 0,
2214
- inputTokens: u.input_tokens ?? 0,
2215
- outputTokens: u.output_tokens ?? 0,
2216
- elapsedMs: Date.now() - t0
2217
- };
2218
- }
2219
- /**
2220
- * Like ask(), but instead of collecting the reply text it re-emits each NEW
2221
- * raw JSONL transcript line via onLine (verbatim) until a terminal
2222
- * stop_reason. Returns the terminal stop_reason + the last assistant usage.
2223
- * Used by the opencode plugin transport shim, which feeds these raw lines
2224
- * into the existing stream-json line handler unchanged.
2225
- */
2226
- async tailTurn(prompt, onLine, perTurnTimeoutMs) {
2227
- if (this.aborted) throw new Error("aborted");
2228
- if (!this.proc || this.exited)
2229
- throw new Error("session not started or already exited");
2230
- const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
2231
- if (this.o.bracketedPaste) {
2232
- this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
2233
- } else {
2234
- this.proc.terminal.write(prompt);
2235
- }
2236
- await this.submitTurn();
2237
- let lastUsage = null;
2238
- let totalOutput = 0;
2239
- let stopReason = null;
2240
- const deadline = Date.now() + timeout;
2241
- while (Date.now() < deadline) {
2242
- await delay(this.o.pollMs);
2243
- if (this.aborted) throw new Error("aborted mid-turn");
2244
- const lines = this.readRawLines();
2245
- const lastComplete = lines.length - 1;
2246
- if (lastComplete <= this.cursor) {
2247
- if (this.exited) {
2248
- throw new Error(this.failureMessage("claude exited mid-turn", true));
2367
+ if (request.method === "tools/call") {
2368
+ const params = request.params ?? {};
2369
+ const toolName = String(params.name ?? "");
2370
+ const input = params.arguments ?? {};
2371
+ if (!tools.some((t) => t.name === toolName)) {
2372
+ writeJson(res, {
2373
+ jsonrpc: "2.0",
2374
+ id: requestId,
2375
+ result: {
2376
+ content: [{ type: "text", text: `Unknown proxy tool: ${toolName}` }],
2377
+ isError: true
2378
+ }
2379
+ });
2380
+ return;
2249
2381
  }
2250
- continue;
2382
+ const interceptor = interceptors?.get(toolName);
2383
+ if (interceptor) {
2384
+ let intercepted;
2385
+ try {
2386
+ intercepted = await interceptor(input);
2387
+ } catch (interceptorError) {
2388
+ const message = interceptorError instanceof Error ? interceptorError.message : String(interceptorError);
2389
+ log.warn("proxy-mcp interceptor failed", { toolName, error: message });
2390
+ intercepted = { kind: "error", message };
2391
+ }
2392
+ writeToolCallResult(res, requestId, intercepted);
2393
+ return;
2394
+ }
2395
+ const callId = crypto2.randomUUID();
2396
+ log.info("proxy-mcp tool call received", {
2397
+ callId,
2398
+ toolName,
2399
+ hasInput: input != null,
2400
+ sse: acceptsEventStream(req.headers.accept)
2401
+ });
2402
+ const channel = { closed: false };
2403
+ if (acceptsEventStream(req.headers.accept)) {
2404
+ sse = openEventStream(res);
2405
+ }
2406
+ res.once("close", () => {
2407
+ sse?.stop();
2408
+ if (res.writableFinished) return;
2409
+ channel.closed = true;
2410
+ log.notice("proxy-mcp client closed a tool call before its result", {
2411
+ callId,
2412
+ toolName
2413
+ });
2414
+ });
2415
+ let timer = null;
2416
+ const result = await new Promise(
2417
+ (resolve4, reject2) => {
2418
+ const entry = {
2419
+ id: callId,
2420
+ toolName,
2421
+ input,
2422
+ resolve: resolve4,
2423
+ reject: reject2,
2424
+ channel
2425
+ };
2426
+ pending.set(callId, entry);
2427
+ const deadlineMs = resolveProxyCallTimeoutMs(
2428
+ toolName,
2429
+ input,
2430
+ timeoutOverrides
2431
+ );
2432
+ timer = setTimeout(() => {
2433
+ if (!pending.has(callId)) return;
2434
+ pending.delete(callId);
2435
+ log.notice("proxy-mcp tool call timed out", {
2436
+ callId,
2437
+ toolName,
2438
+ deadlineMs
2439
+ });
2440
+ reject2(buildProxyTimeoutError(toolName, deadlineMs));
2441
+ }, deadlineMs);
2442
+ calls.emit("call", entry);
2443
+ }
2444
+ ).finally(() => {
2445
+ if (timer) clearTimeout(timer);
2446
+ pending.delete(callId);
2447
+ });
2448
+ if (channel.closed) {
2449
+ log.notice("proxy-mcp dropping result for a closed tool call", {
2450
+ callId,
2451
+ toolName
2452
+ });
2453
+ return;
2454
+ }
2455
+ writeToolCallResult(res, requestId, result, sse);
2456
+ return;
2251
2457
  }
2252
- for (let i = this.cursor; i < lastComplete; i++) {
2253
- const s = lines[i];
2254
- if (!s || !s.trim()) continue;
2255
- onLine(s);
2256
- let rec;
2458
+ writeJson(res, {
2459
+ jsonrpc: "2.0",
2460
+ id: requestId,
2461
+ error: { code: -32601, message: `Unknown method: ${request.method}` }
2462
+ });
2463
+ } catch (error) {
2464
+ const errorMessage = error instanceof Error ? error.message : String(error);
2465
+ const logFn = isExpectedCleanupError(errorMessage) ? log.notice : log.warn;
2466
+ logFn("proxy-mcp error handling request", {
2467
+ error: errorMessage
2468
+ });
2469
+ if (requestMethod === "tools/call") {
2257
2470
  try {
2258
- rec = JSON.parse(s);
2471
+ writeToolCallResult(
2472
+ res,
2473
+ requestId,
2474
+ { kind: "error", message: errorMessage },
2475
+ sse
2476
+ );
2259
2477
  } catch {
2260
- continue;
2261
- }
2262
- if (rec.type === "assistant" && rec.message) {
2263
- if (rec.message.usage) {
2264
- lastUsage = rec.message.usage;
2265
- totalOutput += rec.message.usage.output_tokens ?? 0;
2266
- }
2267
- if (rec.message.stop_reason && TERMINAL_STOP.has(rec.message.stop_reason)) {
2268
- stopReason = rec.message.stop_reason;
2478
+ try {
2479
+ res.statusCode = 500;
2480
+ res.end();
2481
+ } catch {
2269
2482
  }
2270
2483
  }
2271
- }
2272
- this.cursor = lastComplete;
2273
- if (stopReason) break;
2274
- }
2275
- let usage = lastUsage;
2276
- if (lastUsage) {
2277
- usage = { ...lastUsage, output_tokens: totalOutput };
2278
- if (Array.isArray(lastUsage.iterations) && lastUsage.iterations.length > 0) {
2279
- const iters = lastUsage.iterations.map((it) => ({ ...it }));
2280
- iters[iters.length - 1] = {
2281
- ...iters[iters.length - 1],
2282
- output_tokens: totalOutput
2283
- };
2284
- usage.iterations = iters;
2285
- }
2286
- }
2287
- if (!stopReason) {
2288
- throw new Error(
2289
- this.failureMessage(
2290
- `turn timed out after ${timeout}ms (no terminal assistant record)`
2291
- )
2292
- );
2293
- }
2294
- return { stopReason, usage };
2295
- }
2296
- dispose() {
2297
- if (this.proc) {
2298
- try {
2299
- this.proc.terminal.write("");
2300
- } catch {
2301
- }
2302
- try {
2303
- this.proc.kill();
2304
- } catch {
2484
+ return;
2305
2485
  }
2306
2486
  try {
2307
- this.proc.terminal.close();
2487
+ writeJson(res, {
2488
+ jsonrpc: "2.0",
2489
+ id: requestId,
2490
+ error: {
2491
+ code: -32603,
2492
+ message: error instanceof Error ? error.message : "Internal error"
2493
+ }
2494
+ });
2308
2495
  } catch {
2496
+ try {
2497
+ res.statusCode = 500;
2498
+ res.end();
2499
+ } catch {
2500
+ }
2309
2501
  }
2310
2502
  }
2311
- this.proc = null;
2312
- }
2313
- };
2314
-
2315
- // src/claude-session-wrapper.ts
2316
- function decodeUserEnvelope(chunk) {
2317
- let parsed;
2318
- try {
2319
- parsed = JSON.parse(chunk);
2320
- } catch {
2321
- return chunk;
2322
- }
2323
- if (!parsed || parsed.type !== "user" || !parsed.message) return chunk;
2324
- const content = parsed.message.content;
2325
- if (typeof content === "string") return content;
2326
- if (!Array.isArray(content)) return chunk;
2327
- const parts = [];
2328
- let dropped = 0;
2329
- for (const block of content) {
2330
- if (block?.type === "text" && typeof block.text === "string") {
2331
- parts.push(block.text);
2332
- } else if (block?.type === "tool_result") {
2333
- const v = block.content;
2334
- const text = typeof v === "string" ? v : Array.isArray(v) ? v.map((i) => i?.type === "text" ? i.text : "").filter(Boolean).join("\n") : "";
2335
- parts.push(
2336
- `[Tool result${block.tool_use_id ? ` ${block.tool_use_id}` : ""}]
2337
- ${text}`
2338
- );
2339
- } else {
2340
- dropped++;
2341
- }
2342
- }
2343
- if (dropped > 0) {
2344
- log.warn("interactive transport dropped non-text content blocks", {
2345
- dropped
2503
+ });
2504
+ await new Promise((resolve4, reject2) => {
2505
+ server2.once("error", reject2);
2506
+ server2.listen(0, "127.0.0.1", () => {
2507
+ server2.off("error", reject2);
2508
+ resolve4();
2346
2509
  });
2347
- }
2348
- return parts.join("\n\n");
2349
- }
2350
- function spawnInteractiveProcess(opts) {
2351
- const extraArgs = [];
2352
- if (opts.mcpConfigPaths && opts.mcpConfigPaths.length > 0) {
2353
- extraArgs.push(
2354
- "--mcp-config",
2355
- ...opts.mcpConfigPaths,
2356
- "--strict-mcp-config"
2357
- );
2358
- }
2359
- const flagSettings = {};
2360
- if (opts.permissionsAllow && opts.permissionsAllow.length > 0) {
2361
- flagSettings.permissions = { allow: opts.permissionsAllow };
2362
- }
2363
- if (opts.fastMode) {
2364
- flagSettings.fastMode = true;
2365
- }
2366
- if (Object.keys(flagSettings).length > 0) {
2367
- extraArgs.push("--settings", JSON.stringify(flagSettings));
2368
- }
2369
- if (opts.permissionMode === "bypassPermissions") {
2370
- log.warn(
2371
- "interactive permissionMode bypassPermissions ignored: Claude Code prompts for confirmation in the TUI"
2372
- );
2373
- } else if (opts.permissionMode) {
2374
- extraArgs.push("--permission-mode", opts.permissionMode);
2375
- }
2376
- if (opts.systemPromptFile) {
2377
- extraArgs.push("--append-system-prompt-file", opts.systemPromptFile);
2378
- }
2379
- const session = new ClaudeSession({
2380
- cwd: opts.cwd,
2381
- cliPath: opts.cliPath,
2382
- configDir: opts.configDir,
2383
- model: opts.model,
2384
- // Default null = normal CLAUDE.md + settings load, matching what the
2385
- // headless spawn does. "" (skip everything) is for fast e2e runs only.
2386
- settingSources: opts.settingSources === void 0 ? null : opts.settingSources,
2387
- extraArgs,
2388
- ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey
2389
2510
  });
2390
- log.info("prepared interactive claude session", {
2391
- cwd: opts.cwd,
2392
- cliPath: opts.cliPath ?? "claude",
2393
- configDir: session.configDir,
2394
- model: opts.model,
2395
- sessionId: session.sessionId,
2396
- jsonlPath: session.jsonlPath
2511
+ const addr = server2.address();
2512
+ if (!addr) {
2513
+ server2.close();
2514
+ throw new Error("Failed to bind proxy MCP server");
2515
+ }
2516
+ boundAuthority = `127.0.0.1:${addr.port}`;
2517
+ const url = `http://${boundAuthority}/mcp`;
2518
+ log.info("proxy-mcp server started", {
2519
+ url,
2520
+ tools: tools.map((t) => t.name)
2397
2521
  });
2398
- const lineEmitter = new EventEmitter2();
2399
- const errorHandlers = /* @__PURE__ */ new Set();
2400
- let startPromise = null;
2401
- const ensureStarted = () => {
2402
- if (!startPromise) startPromise = session.start();
2403
- return startPromise;
2404
- };
2405
- const emitResult = (subtype, isError, result, usage) => {
2406
- lineEmitter.emit(
2407
- "line",
2408
- JSON.stringify({
2409
- type: "result",
2410
- subtype,
2411
- is_error: isError,
2412
- result,
2413
- session_id: session.sessionId,
2414
- usage: usage ?? {},
2415
- total_cost_usd: null,
2416
- duration_ms: 0
2417
- })
2418
- );
2419
- };
2420
- const runTurn = (userMsg) => {
2421
- void (async () => {
2422
- try {
2423
- await ensureStarted();
2424
- const { stopReason, usage } = await session.tailTurn(userMsg, (raw) => {
2425
- lineEmitter.emit("line", raw);
2426
- });
2427
- const timedOut = !stopReason;
2428
- emitResult(
2429
- timedOut ? "error_during_execution" : stopReason,
2430
- timedOut,
2431
- timedOut ? "Interactive transport: the turn ended without a terminal stop_reason (turn timeout or claude exit). Output above may be incomplete." : void 0,
2432
- usage
2433
- );
2434
- } catch (err) {
2435
- const e = err instanceof Error ? err : new Error(String(err));
2436
- log.error("interactive turn failed", { error: e.message });
2437
- emitResult(
2438
- "error_during_execution",
2439
- true,
2440
- `Interactive transport failed: ${e.message}`
2441
- );
2442
- if (errorHandlers.size > 0) {
2443
- for (const h of errorHandlers) h(e);
2444
- } else {
2445
- lineEmitter.emit("close");
2446
- }
2447
- }
2448
- })();
2449
- };
2450
- const proc = {
2451
- stdin: {
2452
- write(chunk) {
2453
- const raw = typeof chunk === "string" && chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk;
2454
- runTurn(decodeUserEnvelope(raw));
2455
- return true;
2456
- },
2457
- end() {
2458
- }
2459
- },
2460
- stdout: null,
2461
- stderr: null,
2462
- pid: -1,
2463
- killed: false,
2464
- on(event, fn) {
2465
- if (event === "error") errorHandlers.add(fn);
2466
- return proc;
2467
- },
2468
- once() {
2469
- return proc;
2470
- },
2471
- off(event, fn) {
2472
- if (event === "error") errorHandlers.delete(fn);
2473
- return proc;
2522
+ let configFilePath = null;
2523
+ const api = {
2524
+ url,
2525
+ serverName: SERVER_NAME,
2526
+ tools,
2527
+ authToken,
2528
+ calls,
2529
+ configPath() {
2530
+ if (configFilePath) return configFilePath;
2531
+ const body = JSON.stringify(
2532
+ {
2533
+ mcpServers: {
2534
+ [SERVER_NAME]: {
2535
+ type: "http",
2536
+ url,
2537
+ // Claude CLI replays these headers on every request to this
2538
+ // server, which is what lets the handler above reject anyone
2539
+ // who did not read this 0600 file.
2540
+ headers: { Authorization: `Bearer ${authToken}` },
2541
+ timeout: resolveProxyClientCeilingMs(timeoutOverrides)
2542
+ }
2543
+ }
2544
+ },
2545
+ null,
2546
+ 2
2547
+ );
2548
+ const hash = crypto2.createHash("sha256").update(body).digest("hex").slice(0, 12);
2549
+ const outPath = path4.join(
2550
+ pluginTmpDir(),
2551
+ `proxy-${hash}.json`
2552
+ );
2553
+ fs3.writeFileSync(outPath, body, { encoding: "utf8", mode: 384 });
2554
+ configFilePath = outPath;
2555
+ return outPath;
2474
2556
  },
2475
- kill() {
2476
- try {
2477
- session.dispose();
2478
- } catch {
2557
+ async close() {
2558
+ for (const entry of pending.values()) {
2559
+ entry.reject(new Error(SERVER_CLOSED_MESSAGE));
2479
2560
  }
2480
- if (opts.systemPromptFile) {
2481
- void unlink2(opts.systemPromptFile).catch(() => {
2482
- });
2561
+ pending.clear();
2562
+ await new Promise((resolve4) => {
2563
+ server2.close(() => resolve4());
2564
+ });
2565
+ if (configFilePath) {
2566
+ try {
2567
+ fs3.unlinkSync(configFilePath);
2568
+ } catch {
2569
+ }
2570
+ configFilePath = null;
2483
2571
  }
2484
- proc.killed = true;
2485
- return true;
2486
2572
  }
2487
2573
  };
2488
- return {
2489
- proc,
2490
- lineEmitter,
2491
- proxyServer: null,
2492
- mcpHash: void 0,
2493
- systemPromptFile: opts.systemPromptFile
2494
- };
2495
- }
2496
-
2497
- // src/compression-store.ts
2498
- var MAX_COMPRESSION_ENTRIES = 32;
2499
- var compressions = /* @__PURE__ */ new Map();
2500
- function storeCompressionSummary(sessionKey2, summary) {
2501
- compressions.set(sessionKey2, { summary, restartPending: true });
2502
- while (compressions.size > MAX_COMPRESSION_ENTRIES) {
2503
- const oldest = compressions.keys().next();
2504
- if (oldest.done) break;
2505
- compressions.delete(oldest.value);
2506
- log.info("compression store evicted oldest entry", { sessionKey: oldest.value });
2507
- }
2508
- }
2509
- function getCompressionSummary(sessionKey2) {
2510
- return compressions.get(sessionKey2)?.summary;
2511
- }
2512
- function consumeCompressionRestart(sessionKey2) {
2513
- const state = compressions.get(sessionKey2);
2514
- if (!state?.restartPending) return false;
2515
- state.restartPending = false;
2516
- return true;
2517
- }
2518
- function clearCompression(sessionKey2) {
2519
- compressions.delete(sessionKey2);
2574
+ return api;
2520
2575
  }
2521
-
2522
- // src/proxy-mcp.ts
2523
- import { createServer } from "http";
2524
- import * as fs4 from "fs";
2525
- import * as path4 from "path";
2526
- import * as crypto2 from "crypto";
2527
- import { EventEmitter as EventEmitter3 } from "events";
2528
- var SERVER_CLOSED_MESSAGE = "proxy MCP server closed";
2529
- function isExpectedCleanupError(message) {
2530
- return message.includes("timed out after") && message.includes("waiting for opencode to resolve") || message.includes("rejecting as orphaned") || message.includes("was orphaned by a new user turn") || message.includes("stream was aborted") || message.includes(SERVER_CLOSED_MESSAGE);
2531
- }
2532
- var PROTOCOL_VERSION = "2024-11-05";
2533
- var SERVER_NAME = "opencode_proxy";
2534
- var PROXY_TOOL_PREFIX = `mcp__${SERVER_NAME}__`;
2535
- var PROXY_DEFAULT_TIMEOUT_MS = 10 * 60 * 1e3;
2536
- var PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS = {
2537
- task: 60 * 60 * 1e3,
2538
- // 60 min
2539
- question: 30 * 60 * 1e3
2540
- // 30 min
2541
- };
2542
- var MAX_PROXY_TIMEOUT_MS = 2 ** 31 - 1;
2543
- function resolveProxyCallTimeoutMs(toolName, input, overrides) {
2544
- const key = toolName.toLowerCase();
2545
- let ms = PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS[key] ?? PROXY_DEFAULT_TIMEOUT_MS;
2546
- if (overrides) {
2547
- const ov = lookupCaseInsensitive(overrides, key);
2548
- if (typeof ov === "number" && ov > 0) ms = ov;
2549
- }
2550
- if (key === "bash") {
2551
- const requested = input?.timeout;
2552
- if (typeof requested === "number" && requested > ms) ms = requested;
2576
+ function disallowedToolFlags(tools) {
2577
+ const nameMap = {
2578
+ bash: ["Bash"],
2579
+ read: ["Read"],
2580
+ write: ["Write"],
2581
+ edit: ["Edit", "MultiEdit"],
2582
+ glob: ["Glob"],
2583
+ grep: ["Grep"],
2584
+ webfetch: ["WebFetch"],
2585
+ task: ["Agent"],
2586
+ // `question` disables Claude Code's built-in `AskUserQuestion` so the
2587
+ // structured-questions path flows through opencode's native `question`
2588
+ // tool instead — same UI/permission/audit benefits as the other
2589
+ // proxies. Without this, the model can call both and the two paths
2590
+ // diverge (opencode's form vs the headless deny-and-render fallback).
2591
+ question: ["AskUserQuestion"]
2592
+ };
2593
+ const out = [];
2594
+ const seen = /* @__PURE__ */ new Set();
2595
+ for (const t of tools) {
2596
+ const mapped = nameMap[t.name.toLowerCase()];
2597
+ if (!mapped) continue;
2598
+ for (const claudeTool of mapped) {
2599
+ if (seen.has(claudeTool)) continue;
2600
+ seen.add(claudeTool);
2601
+ out.push(claudeTool);
2602
+ }
2553
2603
  }
2554
- return Math.min(ms, MAX_PROXY_TIMEOUT_MS);
2604
+ return out;
2555
2605
  }
2556
- function lookupCaseInsensitive(map, key) {
2557
- if (Object.prototype.hasOwnProperty.call(map, key)) return map[key];
2558
- for (const k of Object.keys(map)) {
2559
- if (k.toLowerCase() === key) return map[k];
2560
- }
2561
- return void 0;
2606
+ function resolveDisallowedTools(options) {
2607
+ const out = [];
2608
+ const seen = /* @__PURE__ */ new Set();
2609
+ const push = (name) => {
2610
+ const trimmed = name.trim();
2611
+ if (!trimmed || seen.has(trimmed)) return;
2612
+ seen.add(trimmed);
2613
+ out.push(trimmed);
2614
+ };
2615
+ for (const name of disallowedToolFlags(options.proxyTools ?? [])) push(name);
2616
+ for (const name of options.extraDisallowedTools ?? []) push(String(name));
2617
+ if (options.disableWebSearch) push("WebSearch");
2618
+ return out;
2562
2619
  }
2563
- function resolveProxyClientCeilingMs(overrides) {
2564
- let ms = PROXY_DEFAULT_TIMEOUT_MS;
2565
- for (const v of Object.values(PROXY_PER_TOOL_DEFAULT_TIMEOUT_MS)) {
2566
- if (v > ms) ms = v;
2567
- }
2568
- if (overrides) {
2569
- for (const v of Object.values(overrides)) {
2570
- if (typeof v === "number" && v > ms) ms = v;
2620
+ function readBody(req) {
2621
+ return new Promise((resolve4, reject) => {
2622
+ const chunks = [];
2623
+ req.on("data", (chunk) => chunks.push(chunk));
2624
+ req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
2625
+ req.on("error", reject);
2626
+ });
2627
+ }
2628
+ function writeToolCallResult(res, requestId, result, sse = null) {
2629
+ const text = result.kind === "error" ? result.message : result.text;
2630
+ const isError = result.kind === "error" || result.isError === true;
2631
+ const envelope = {
2632
+ jsonrpc: "2.0",
2633
+ id: requestId ?? null,
2634
+ result: {
2635
+ content: [{ type: "text", text }],
2636
+ isError
2571
2637
  }
2638
+ };
2639
+ if (sse) {
2640
+ sse.finish(envelope);
2641
+ return;
2572
2642
  }
2573
- return Math.min(ms, MAX_PROXY_TIMEOUT_MS);
2643
+ writeJson(res, envelope);
2574
2644
  }
2575
- function buildProxyTimeoutError(toolName, ms) {
2576
- const key = toolName.toLowerCase();
2577
- const base = `Proxy tool '${toolName}' timed out after ${ms}ms waiting for opencode to resolve the call`;
2578
- if (key === "task") {
2579
- return new Error(
2580
- base + " (the subagent). The subagent may still be running but its result is no longer reachable in this session. Do not declare the dispatch failed, and do not 'schedule a wake-up' or defer -- that mechanism does not apply here. If the result is required, re-dispatch or verify it directly now."
2581
- );
2645
+ function openEventStream(res) {
2646
+ res.statusCode = 200;
2647
+ res.setHeader("Content-Type", "text/event-stream");
2648
+ res.setHeader("Cache-Control", "no-cache, no-transform");
2649
+ res.setHeader("Connection", "keep-alive");
2650
+ res.flushHeaders();
2651
+ res.write(": open\n\n");
2652
+ let timer = setInterval(() => {
2653
+ if (res.writableEnded || res.destroyed) {
2654
+ stop();
2655
+ return;
2656
+ }
2657
+ res.write(": keepalive\n\n");
2658
+ }, SSE_KEEPALIVE_MS);
2659
+ timer.unref?.();
2660
+ const stop = () => {
2661
+ if (timer) {
2662
+ clearInterval(timer);
2663
+ timer = null;
2664
+ }
2665
+ };
2666
+ return {
2667
+ stop,
2668
+ finish(envelope) {
2669
+ stop();
2670
+ if (res.writableEnded || res.destroyed) return;
2671
+ res.end(`event: message
2672
+ data: ${JSON.stringify(envelope)}
2673
+
2674
+ `);
2675
+ }
2676
+ };
2677
+ }
2678
+ function writeJson(res, body) {
2679
+ if (res.destroyed || res.writableEnded) return;
2680
+ const payload = JSON.stringify(body);
2681
+ res.statusCode = 200;
2682
+ res.setHeader("Content-Type", "application/json");
2683
+ res.setHeader("Content-Length", Buffer.byteLength(payload).toString());
2684
+ res.end(payload);
2685
+ }
2686
+
2687
+ // src/proxy-broker.ts
2688
+ var pendingByCallId = /* @__PURE__ */ new Map();
2689
+ var callIdsBySession = /* @__PURE__ */ new Map();
2690
+ var emitter = new EventEmitter2();
2691
+ function eventName(sessionKey2) {
2692
+ return `pending:${sessionKey2}`;
2693
+ }
2694
+ function indexAdd(sessionKey2, callId) {
2695
+ let s = callIdsBySession.get(sessionKey2);
2696
+ if (!s) {
2697
+ s = /* @__PURE__ */ new Set();
2698
+ callIdsBySession.set(sessionKey2, s);
2582
2699
  }
2583
- return new Error(base);
2700
+ s.add(callId);
2584
2701
  }
2585
- var TASK_PROXY_NOTE = "This is the ONLY tool that dispatches opencode subagents (including user @-mentions). Claude Code's built-in TaskCreate/TaskUpdate manage a local todo list and cannot dispatch subagents. Do not search config files to verify a subagent type exists \u2014 invalid types fail fast with a clear error. Foreground calls block until the subagent finishes; set `background` to request opencode's background execution mode. Task calls get a 60-minute proxy deadline by default (configurable via proxyToolTimeoutMs).";
2586
- var AGENT_TYPES_HEADING = "Available agent types";
2587
- var AGENT_BLURB_LIMIT = 140;
2588
- var QUESTION_PROXY_NOTE = "This routes structured questions through opencode's native `question` tool, which renders a TUI form with the options you provide and blocks until the operator answers. Claude Code's built-in AskUserQuestion is disabled in this environment; this proxy is the ONLY way to ask the operator for a decision or clarification. Answers come back as arrays of selected labels (set `multiple: true` to allow more than one). If the operator dismisses the form the call returns an error \u2014 treat that as 'no answer' and stop, do not guess. Question calls get a 30-minute proxy deadline by default (configurable via proxyToolTimeoutMs); for long-AFK scenarios prefer fewer, high-signal questions.";
2589
- var COMPRESS_PROXY_NOTE = "The current turn continues normally after this call \u2014 finish what you are doing. The reset happens at the START of the next turn: the Claude Code session is discarded and a fresh one begins with your summary as its only prior context. Everything else, including tool output and files you read, is gone, so write the summary as the authoritative record. Call this once per compression, when older resolved work no longer needs full detail.";
2590
- function extractAgentTypeList(liveDescription) {
2591
- const live = liveDescription?.trim();
2592
- if (!live) return void 0;
2593
- const start = live.indexOf(AGENT_TYPES_HEADING);
2594
- if (start === -1) return void 0;
2595
- const entries = [];
2596
- for (const raw of live.slice(start).split("\n")) {
2597
- const match = /^-\s*([^:]+):\s*(.+)$/.exec(raw.trim());
2598
- if (!match) continue;
2599
- const name = match[1].trim();
2600
- const blurb = match[2].trim();
2601
- entries.push(
2602
- `- ${name}: ${blurb.length > AGENT_BLURB_LIMIT ? `${blurb.slice(0, AGENT_BLURB_LIMIT).trimEnd()}\u2026` : blurb}`
2702
+ function indexRemove(sessionKey2, callId) {
2703
+ const s = callIdsBySession.get(sessionKey2);
2704
+ if (!s) return;
2705
+ s.delete(callId);
2706
+ if (s.size === 0) callIdsBySession.delete(sessionKey2);
2707
+ }
2708
+ function onPendingProxyCall(sessionKey2, handler) {
2709
+ const name = eventName(sessionKey2);
2710
+ emitter.on(name, handler);
2711
+ return () => emitter.off(name, handler);
2712
+ }
2713
+ function queuePendingProxyCall(sessionKey2, call, timeoutOverrides) {
2714
+ const previous = pendingByCallId.get(call.id);
2715
+ if (previous) {
2716
+ clearTimeout(previous.timer);
2717
+ previous.reject(
2718
+ new Error(`Replaced pending proxy call ${call.id} with a fresh one`)
2603
2719
  );
2720
+ pendingByCallId.delete(call.id);
2721
+ indexRemove(previous.sessionKey, call.id);
2604
2722
  }
2605
- if (entries.length === 0) return void 0;
2606
- return `Valid subagent_type values, from opencode's live registry \u2014 anything else fails:
2607
- ${entries.join("\n")}`;
2608
- }
2609
- function overlayTaskProxyDescription(tools, liveDescription) {
2610
- const agentTypes = extractAgentTypeList(liveDescription);
2611
- if (!agentTypes) return tools;
2612
- return tools.map(
2613
- (t) => t.name === "task" ? { ...t, description: `${agentTypes}
2614
-
2615
- ${t.description}` } : t
2723
+ const deadlineMs = resolveProxyCallTimeoutMs(
2724
+ call.toolName,
2725
+ call.input,
2726
+ timeoutOverrides
2616
2727
  );
2728
+ const timer = setTimeout(() => {
2729
+ const current = pendingByCallId.get(call.id);
2730
+ if (!current) return;
2731
+ pendingByCallId.delete(call.id);
2732
+ indexRemove(current.sessionKey, call.id);
2733
+ current.reject(buildProxyTimeoutError(call.toolName, deadlineMs));
2734
+ log.notice("timed out pending proxy call", {
2735
+ sessionKey: current.sessionKey,
2736
+ toolCallId: call.id,
2737
+ toolName: call.toolName,
2738
+ deadlineMs
2739
+ });
2740
+ }, deadlineMs);
2741
+ const pending = {
2742
+ sessionKey: sessionKey2,
2743
+ toolCallId: call.id,
2744
+ toolName: call.toolName,
2745
+ input: call.input,
2746
+ channel: call.channel,
2747
+ createdAt: Date.now(),
2748
+ timer,
2749
+ resolve: call.resolve,
2750
+ reject: call.reject
2751
+ };
2752
+ pendingByCallId.set(call.id, pending);
2753
+ indexAdd(sessionKey2, call.id);
2754
+ emitter.emit(eventName(sessionKey2), pending);
2755
+ log.info("queued pending proxy call", {
2756
+ sessionKey: sessionKey2,
2757
+ toolCallId: call.id,
2758
+ toolName: call.toolName
2759
+ });
2760
+ return pending;
2617
2761
  }
2618
- function overlayQuestionProxyDescription(tools, liveDescription) {
2619
- const live = liveDescription?.trim();
2620
- if (!live) return tools;
2621
- return tools.map(
2622
- (t) => t.name === "question" ? { ...t, description: `${live}
2623
-
2624
- ${QUESTION_PROXY_NOTE}` } : t
2625
- );
2762
+ function markPendingProxyCallEmitted(toolCallId) {
2763
+ const pending = pendingByCallId.get(toolCallId);
2764
+ if (pending) pending.emitted = true;
2626
2765
  }
2627
- function filterQuestionProxyByOpencodeSupport(tools, opencodeHasQuestion) {
2628
- if (opencodeHasQuestion) return tools;
2629
- return tools.filter((t) => t.name !== "question");
2766
+ function isPendingProxyCallChannelClosed(call) {
2767
+ return call.channel?.closed === true;
2630
2768
  }
2631
- var DEFAULT_PROXY_TOOLS = [
2632
- {
2633
- name: "bash",
2634
- description: "Execute a shell command. Routed through opencode's bash tool so permission prompts flow through opencode's UI.",
2635
- inputSchema: {
2636
- type: "object",
2637
- properties: {
2638
- command: {
2639
- type: "string",
2640
- description: "The shell command to execute."
2641
- },
2642
- description: {
2643
- type: "string",
2644
- description: "Short human-readable description of what the command does."
2645
- },
2646
- timeout: {
2647
- type: "number",
2648
- description: "Optional timeout in milliseconds."
2649
- }
2650
- },
2651
- required: ["command"]
2652
- }
2653
- },
2654
- {
2655
- name: "write",
2656
- description: "Write a file. Routed through opencode's write tool so permission prompts flow through opencode's UI.",
2657
- inputSchema: {
2658
- type: "object",
2659
- properties: {
2660
- filePath: {
2661
- type: "string",
2662
- description: "The file to write. Absolute paths are preferred."
2663
- },
2664
- content: {
2665
- type: "string",
2666
- description: "The full content to write to the file."
2667
- }
2668
- },
2669
- required: ["filePath", "content"]
2670
- }
2671
- },
2672
- {
2673
- name: "edit",
2674
- description: "Replace text in an existing file. Routed through opencode's edit tool so permission prompts flow through opencode's UI.",
2675
- inputSchema: {
2676
- type: "object",
2677
- properties: {
2678
- filePath: {
2679
- type: "string",
2680
- description: "The file to edit. Absolute paths are preferred."
2681
- },
2682
- oldString: {
2683
- type: "string",
2684
- description: "The exact text to replace."
2685
- },
2686
- newString: {
2687
- type: "string",
2688
- description: "The replacement text."
2689
- },
2690
- replaceAll: {
2691
- type: "boolean",
2692
- description: "Replace all occurrences instead of just the first one."
2693
- }
2694
- },
2695
- required: ["filePath", "oldString", "newString"]
2769
+ function getPendingProxyCalls(sessionKey2) {
2770
+ const s = callIdsBySession.get(sessionKey2);
2771
+ if (!s || s.size === 0) return [];
2772
+ const out = [];
2773
+ for (const id of s) {
2774
+ const p = pendingByCallId.get(id);
2775
+ if (p) out.push(p);
2776
+ }
2777
+ return out;
2778
+ }
2779
+ function resolvePendingProxyCallById(toolCallId, result) {
2780
+ const pending = pendingByCallId.get(toolCallId);
2781
+ if (!pending) return false;
2782
+ pendingByCallId.delete(toolCallId);
2783
+ indexRemove(pending.sessionKey, toolCallId);
2784
+ clearTimeout(pending.timer);
2785
+ pending.resolve(result);
2786
+ log.info("resolved pending proxy call", {
2787
+ sessionKey: pending.sessionKey,
2788
+ toolCallId: pending.toolCallId,
2789
+ toolName: pending.toolName
2790
+ });
2791
+ return true;
2792
+ }
2793
+ function rejectPendingProxyCallById(toolCallId, error) {
2794
+ const pending = pendingByCallId.get(toolCallId);
2795
+ if (!pending) return false;
2796
+ pendingByCallId.delete(toolCallId);
2797
+ indexRemove(pending.sessionKey, toolCallId);
2798
+ clearTimeout(pending.timer);
2799
+ pending.reject(error);
2800
+ log.notice("rejected pending proxy call", {
2801
+ sessionKey: pending.sessionKey,
2802
+ toolCallId: pending.toolCallId,
2803
+ toolName: pending.toolName,
2804
+ error: error.message
2805
+ });
2806
+ return true;
2807
+ }
2808
+ function rejectAllPendingProxyCallsForSession(sessionKey2, error) {
2809
+ const s = callIdsBySession.get(sessionKey2);
2810
+ if (!s) return 0;
2811
+ const ids = [...s];
2812
+ let count = 0;
2813
+ for (const id of ids) {
2814
+ if (rejectPendingProxyCallById(id, error)) count++;
2815
+ }
2816
+ return count;
2817
+ }
2818
+
2819
+ // src/compression-store.ts
2820
+ var MAX_COMPRESSION_ENTRIES = 32;
2821
+ var compressions = /* @__PURE__ */ new Map();
2822
+ function storeCompressionSummary(sessionKey2, summary) {
2823
+ compressions.set(sessionKey2, { summary, restartPending: true });
2824
+ while (compressions.size > MAX_COMPRESSION_ENTRIES) {
2825
+ const oldest = compressions.keys().next();
2826
+ if (oldest.done) break;
2827
+ compressions.delete(oldest.value);
2828
+ log.info("compression store evicted oldest entry", { sessionKey: oldest.value });
2829
+ }
2830
+ }
2831
+ function getCompressionSummary(sessionKey2) {
2832
+ return compressions.get(sessionKey2)?.summary;
2833
+ }
2834
+ function consumeCompressionRestart(sessionKey2) {
2835
+ const state = compressions.get(sessionKey2);
2836
+ if (!state?.restartPending) return false;
2837
+ state.restartPending = false;
2838
+ return true;
2839
+ }
2840
+ function clearCompression(sessionKey2) {
2841
+ compressions.delete(sessionKey2);
2842
+ }
2843
+
2844
+ // src/session-manager.ts
2845
+ var UNATTENDED_LINE_CAP = 500;
2846
+ var UNATTENDED_BYTE_CAP = 2 * 1024 * 1024;
2847
+ function bufferUnattendedLine(ap, line) {
2848
+ const lines = ap.unattendedLines ??= [];
2849
+ lines.push(line);
2850
+ let bytes = 0;
2851
+ for (const kept of lines) bytes += Buffer.byteLength(kept);
2852
+ while (lines.length > 0 && (lines.length > UNATTENDED_LINE_CAP || bytes > UNATTENDED_BYTE_CAP)) {
2853
+ bytes -= Buffer.byteLength(lines.shift());
2854
+ ap.unattendedDropped = (ap.unattendedDropped ?? 0) + 1;
2855
+ }
2856
+ }
2857
+ function takeUnattendedLines(ap) {
2858
+ const lines = ap.unattendedLines ?? [];
2859
+ const dropped = ap.unattendedDropped ?? 0;
2860
+ ap.unattendedLines = [];
2861
+ ap.unattendedDropped = 0;
2862
+ return { lines, dropped };
2863
+ }
2864
+ var activeProcesses = /* @__PURE__ */ new Map();
2865
+ var claudeSessions = /* @__PURE__ */ new Map();
2866
+ var MAX_ACTIVE_PROCESSES = 16;
2867
+ var PROCESS_EXIT_TIMEOUT_MS = 1500;
2868
+ var PROCESS_FORCE_EXIT_TIMEOUT_MS = 500;
2869
+ function envFlagEnabled(value) {
2870
+ if (value === void 0) return false;
2871
+ const normalized = value.trim().toLowerCase();
2872
+ if (!normalized) return false;
2873
+ return !["0", "false", "no", "off"].includes(normalized);
2874
+ }
2875
+ function isClaudeThinkingDisabled() {
2876
+ return envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_THINKING) || envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING);
2877
+ }
2878
+ function cliEffortLevel(effort) {
2879
+ return effort === "minimal" ? "low" : effort;
2880
+ }
2881
+ function claudeSpawnEnv(opts) {
2882
+ const env = {
2883
+ ...process.env,
2884
+ TERM: "xterm-256color"
2885
+ };
2886
+ if (opts?.effort) {
2887
+ env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort);
2888
+ }
2889
+ if (opts?.ignoreAnthropicApiKey) {
2890
+ delete env.ANTHROPIC_API_KEY;
2891
+ delete env.ANTHROPIC_AUTH_TOKEN;
2892
+ }
2893
+ if (!isClaudeThinkingDisabled() && process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES === void 0) {
2894
+ env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = "1";
2895
+ }
2896
+ return env;
2897
+ }
2898
+ function touch(key) {
2899
+ const existing = activeProcesses.get(key);
2900
+ if (existing) {
2901
+ activeProcesses.delete(key);
2902
+ activeProcesses.set(key, existing);
2903
+ }
2904
+ }
2905
+ function evictIfNeeded() {
2906
+ while (activeProcesses.size >= MAX_ACTIVE_PROCESSES) {
2907
+ const oldestKey = activeProcesses.keys().next().value;
2908
+ if (!oldestKey) break;
2909
+ log.info("evicting LRU claude process", { sessionKey: oldestKey });
2910
+ deleteActiveProcess(oldestKey);
2911
+ }
2912
+ }
2913
+ function getActiveProcess(key) {
2914
+ const ap = activeProcesses.get(key);
2915
+ if (ap) touch(key);
2916
+ return ap;
2917
+ }
2918
+ function setActiveProcess(key, ap) {
2919
+ activeProcesses.set(key, ap);
2920
+ }
2921
+ function detachActiveProcess(key) {
2922
+ const ap = activeProcesses.get(key);
2923
+ if (!ap) return void 0;
2924
+ activeProcesses.delete(key);
2925
+ void ap.proxyServer?.close();
2926
+ return ap;
2927
+ }
2928
+ function deleteActiveProcess(key) {
2929
+ const ap = detachActiveProcess(key);
2930
+ ap?.proc.kill();
2931
+ }
2932
+ function hasProcessExited(proc) {
2933
+ return proc.exitCode !== null || proc.signalCode !== null;
2934
+ }
2935
+ function waitForProcessExit(proc, timeoutMs) {
2936
+ if (hasProcessExited(proc)) return Promise.resolve(true);
2937
+ return new Promise((resolve4) => {
2938
+ const onExit = () => {
2939
+ clearTimeout(timer);
2940
+ resolve4(true);
2941
+ };
2942
+ const timer = setTimeout(() => {
2943
+ proc.off("exit", onExit);
2944
+ resolve4(hasProcessExited(proc));
2945
+ }, timeoutMs);
2946
+ proc.once("exit", onExit);
2947
+ });
2948
+ }
2949
+ async function deleteActiveProcessAndWait(key, options = {}) {
2950
+ const ap = detachActiveProcess(key);
2951
+ if (!ap || hasProcessExited(ap.proc)) return true;
2952
+ const gracefulExit = waitForProcessExit(
2953
+ ap.proc,
2954
+ options.exitTimeoutMs ?? PROCESS_EXIT_TIMEOUT_MS
2955
+ );
2956
+ ap.proc.kill();
2957
+ if (await gracefulExit) return true;
2958
+ const forcedExit = waitForProcessExit(
2959
+ ap.proc,
2960
+ options.forceExitTimeoutMs ?? PROCESS_FORCE_EXIT_TIMEOUT_MS
2961
+ );
2962
+ ap.proc.kill("SIGKILL");
2963
+ if (await forcedExit) return true;
2964
+ log.warn("claude process did not exit; starting a fresh session", {
2965
+ sessionKey: key
2966
+ });
2967
+ deleteClaudeSessionId(key);
2968
+ return false;
2969
+ }
2970
+ function getClaudeSessionId(key) {
2971
+ return claudeSessions.get(key);
2972
+ }
2973
+ function setClaudeSessionId(key, sessionId) {
2974
+ claudeSessions.set(key, sessionId);
2975
+ }
2976
+ function deleteClaudeSessionId(key) {
2977
+ clearExitPlanModeQuestions(key);
2978
+ const claudeSessionId = claudeSessions.get(key);
2979
+ if (claudeSessionId) clearLedger(claudeSessionId);
2980
+ claudeSessions.delete(key);
2981
+ }
2982
+ function effortSessionKey(baseKey, effort) {
2983
+ return effort ? `${baseKey}::effort=${effort}` : baseKey;
2984
+ }
2985
+ function invalidateOtherEffortSessions(baseKey, effort) {
2986
+ const levels = [
2987
+ void 0,
2988
+ "minimal",
2989
+ "low",
2990
+ "medium",
2991
+ "high",
2992
+ "xhigh",
2993
+ "max"
2994
+ ];
2995
+ const staleKeys = levels.filter((level) => level !== effort).map((level) => effortSessionKey(baseKey, level));
2996
+ for (const key of staleKeys) {
2997
+ const active = activeProcesses.get(key);
2998
+ if (getPendingProxyCalls(key).length || hasExitPlanModeQuestions(key) || active?.pendingProxyCompletions?.size || active && (active.lineEmitter.listenerCount("line") > 0 || isSideQuestionPending(active))) {
2999
+ throw new Error(
3000
+ "Cannot change reasoning effort while the previous effort session has pending work. Finish that work at its original effort first."
3001
+ );
2696
3002
  }
2697
- },
2698
- {
2699
- name: "webfetch",
2700
- description: "Fetch content from a URL. Routed through opencode's webfetch tool so permission prompts flow through opencode's UI. Returns the page content in the requested format.",
2701
- inputSchema: {
2702
- type: "object",
2703
- properties: {
2704
- url: {
2705
- type: "string",
2706
- description: "The URL to fetch content from. Must start with http:// or https://."
2707
- },
2708
- format: {
2709
- type: "string",
2710
- enum: ["text", "markdown", "html"],
2711
- description: "The format to return the content in. Defaults to markdown."
2712
- },
2713
- timeout: {
2714
- type: "number",
2715
- description: "Optional timeout in seconds (max 120)."
2716
- }
2717
- },
2718
- required: ["url"]
3003
+ }
3004
+ for (const key of staleKeys) {
3005
+ deleteActiveProcess(key);
3006
+ deleteClaudeSessionId(key);
3007
+ clearCompression(key);
3008
+ }
3009
+ }
3010
+ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort) {
3011
+ evictIfNeeded();
3012
+ log.info("spawning new claude process", {
3013
+ cliPath,
3014
+ cliArgs,
3015
+ cwd,
3016
+ sessionKey: sessionKey2,
3017
+ effort
3018
+ });
3019
+ const proc = spawn(cliPath, cliArgs, {
3020
+ cwd,
3021
+ stdio: ["pipe", "pipe", "pipe"],
3022
+ env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort }),
3023
+ shell: process.platform === "win32"
3024
+ });
3025
+ const lineEmitter = new EventEmitter3();
3026
+ const ap = {
3027
+ proc,
3028
+ lineEmitter,
3029
+ proxyServer: proxyServer ?? null,
3030
+ mcpHash,
3031
+ systemPromptFile,
3032
+ effort,
3033
+ cliArgs: [...cliArgs],
3034
+ unattendedLines: [],
3035
+ unattendedDropped: 0
3036
+ };
3037
+ const rl = createInterface({ input: proc.stdout });
3038
+ rl.on("line", (line) => {
3039
+ if (dispatchSideQuestionResponse(ap, line)) return;
3040
+ if (lineEmitter.listenerCount("line") === 0) {
3041
+ bufferUnattendedLine(ap, line);
3042
+ return;
2719
3043
  }
2720
- },
2721
- {
2722
- name: "task",
2723
- description: "Launch an opencode subagent to handle a complex multi-step task autonomously. Routed through opencode's task tool so subagent orchestration, permission, and lifecycle are handled by opencode. Use `subagent_type` to pick which configured subagent runs (e.g. `build`, `general`, `explore`, or any custom subagent declared in opencode.json). " + TASK_PROXY_NOTE,
2724
- inputSchema: {
2725
- type: "object",
2726
- properties: {
2727
- description: {
2728
- type: "string",
2729
- description: "A short (3-5 words) description of the task"
2730
- },
2731
- prompt: {
2732
- type: "string",
2733
- description: "The task for the agent to perform"
2734
- },
2735
- subagent_type: {
2736
- type: "string",
2737
- description: "The type of specialized agent to use for this task"
2738
- },
2739
- task_id: {
2740
- type: "string",
2741
- description: "Set this only if you mean to resume a previous task \u2014 pass the prior task_id to continue the same subagent session instead of creating a fresh one."
2742
- },
2743
- command: {
2744
- type: "string",
2745
- description: "The command that triggered this task"
2746
- },
2747
- background: {
2748
- type: "boolean",
2749
- description: "Run the task in the background when supported by opencode"
2750
- }
2751
- },
2752
- required: ["description", "prompt", "subagent_type"]
3044
+ lineEmitter.emit("line", line);
3045
+ });
3046
+ rl.on("close", () => {
3047
+ lineEmitter.emit("close");
3048
+ });
3049
+ activeProcesses.set(sessionKey2, ap);
3050
+ proc.on("error", (err) => {
3051
+ log.error("claude process error", { sessionKey: sessionKey2, error: err.message });
3052
+ });
3053
+ proc.on("exit", (code, signal) => {
3054
+ log.info("claude process exited", { code, signal, sessionKey: sessionKey2 });
3055
+ void proxyServer?.close();
3056
+ if (systemPromptFile) {
3057
+ void unlink(systemPromptFile).catch(() => {
3058
+ });
2753
3059
  }
2754
- },
2755
- {
2756
- name: "question",
2757
- description: "Ask the operator structured questions with options and receive their answers back. Routed through opencode's native `question` tool so the prompt renders as a real TUI form (with options and a custom-answer field) instead of a plain text turn. Use this when you need a decision, clarification, or preference from the operator mid-task. " + QUESTION_PROXY_NOTE,
2758
- inputSchema: {
2759
- type: "object",
2760
- properties: {
2761
- questions: {
2762
- type: "array",
2763
- description: "Questions to ask.",
2764
- items: {
2765
- type: "object",
2766
- properties: {
2767
- question: {
2768
- type: "string",
2769
- description: "Complete question."
2770
- },
2771
- header: {
2772
- type: "string",
2773
- description: "Very short label (max 30 chars)."
2774
- },
2775
- options: {
2776
- type: "array",
2777
- description: "Available choices.",
2778
- items: {
2779
- type: "object",
2780
- properties: {
2781
- label: {
2782
- type: "string",
2783
- description: "Display text (1-5 words, concise)."
2784
- },
2785
- description: {
2786
- type: "string",
2787
- description: "Explanation of choice."
2788
- }
2789
- },
2790
- required: ["label", "description"]
2791
- }
2792
- },
2793
- multiple: {
2794
- type: "boolean",
2795
- description: "Allow selecting multiple choices. Defaults to false."
2796
- }
2797
- },
2798
- required: ["question", "header", "options"]
2799
- }
2800
- }
2801
- },
2802
- required: ["questions"]
3060
+ const ownsSessionKey = activeProcesses.get(sessionKey2) === ap;
3061
+ if (ownsSessionKey) activeProcesses.delete(sessionKey2);
3062
+ if (ownsSessionKey && code !== 0 && code !== null) {
3063
+ log.info("process exited with error, clearing session", {
3064
+ code,
3065
+ sessionKey: sessionKey2
3066
+ });
3067
+ claudeSessions.delete(sessionKey2);
2803
3068
  }
2804
- },
2805
- {
2806
- name: "compress",
2807
- description: "Replace older conversation detail with a summary you write, then continue in a fresh Claude Code session. Handled inside the plugin, so it never prompts the operator. " + COMPRESS_PROXY_NOTE,
2808
- inputSchema: {
2809
- type: "object",
2810
- properties: {
2811
- summary: {
2812
- type: "string",
2813
- description: "Dense technical summary of the work being compressed: decisions made, files changed, commands run and their outcomes, and what is still open. This is the ONLY prior context that survives, so anything omitted is lost."
2814
- }
2815
- },
2816
- required: ["summary"]
3069
+ });
3070
+ proc.stderr?.on("data", (data) => {
3071
+ const stderr = data.toString();
3072
+ log.debug("stderr", { data: stderr.slice(0, 200) });
3073
+ if (stderr.includes("No conversation found") || stderr.includes("Session ID") && (stderr.includes("already in use") || stderr.includes("not found") || stderr.includes("invalid"))) {
3074
+ if (activeProcesses.get(sessionKey2) === ap) {
3075
+ log.warn("claude session ID error, clearing session", {
3076
+ sessionKey: sessionKey2,
3077
+ error: stderr.slice(0, 200)
3078
+ });
3079
+ claudeSessions.delete(sessionKey2);
3080
+ } else {
3081
+ log.debug("ignoring session ID error from stale claude process", {
3082
+ sessionKey: sessionKey2
3083
+ });
3084
+ }
2817
3085
  }
3086
+ });
3087
+ return ap;
3088
+ }
3089
+ function appendResumeIfNeeded(sessionKey2, cliArgs) {
3090
+ if (cliArgs.includes("--resume") || cliArgs.includes("--session-id")) {
3091
+ return cliArgs;
2818
3092
  }
2819
- ];
2820
- async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides, interceptors) {
2821
- const calls = new EventEmitter3();
2822
- const pending = /* @__PURE__ */ new Map();
2823
- const authToken = crypto2.randomBytes(32).toString("hex");
2824
- const expectedAuth = Buffer.from(`Bearer ${authToken}`);
2825
- let boundAuthority = "";
2826
- function authOk(req) {
2827
- const got = req.headers.authorization;
2828
- if (typeof got !== "string") return false;
2829
- const candidate = Buffer.from(got);
2830
- if (candidate.length !== expectedAuth.length) return false;
2831
- return crypto2.timingSafeEqual(candidate, expectedAuth);
3093
+ const sid = claudeSessions.get(sessionKey2);
3094
+ if (!sid) return cliArgs;
3095
+ return [...cliArgs, "--resume", sid];
3096
+ }
3097
+ function respawnActiveProcess(sessionKey2, cliPath, cliArgs, cwd, ignoreAnthropicApiKey) {
3098
+ const old = activeProcesses.get(sessionKey2);
3099
+ if (!old) return void 0;
3100
+ activeProcesses.delete(sessionKey2);
3101
+ old.proc.removeAllListeners("exit");
3102
+ try {
3103
+ old.proc.kill();
3104
+ } catch {
2832
3105
  }
2833
- function reject(req, res, statusCode, reason) {
2834
- log.notice("proxy-mcp rejected a request", {
2835
- statusCode,
2836
- reason,
2837
- method: req.method,
2838
- hasAuthorization: typeof req.headers.authorization === "string"
2839
- });
2840
- res.statusCode = statusCode;
2841
- res.setHeader("Connection", "close");
2842
- res.on("finish", () => {
2843
- req.socket?.destroy();
3106
+ const replacement = spawnClaudeProcess(
3107
+ cliPath,
3108
+ appendResumeIfNeeded(sessionKey2, old.cliArgs ?? cliArgs),
3109
+ cwd,
3110
+ sessionKey2,
3111
+ old.proxyServer,
3112
+ old.mcpHash,
3113
+ old.systemPromptFile,
3114
+ ignoreAnthropicApiKey,
3115
+ old.effort
3116
+ );
3117
+ replacement.pendingProxyCompletions = old.pendingProxyCompletions;
3118
+ delete old.pendingProxyCompletions;
3119
+ return replacement;
3120
+ }
3121
+ function buildCliArgs(opts) {
3122
+ const {
3123
+ sessionKey: sessionKey2,
3124
+ skipPermissions,
3125
+ includeSessionId = true,
3126
+ model,
3127
+ permissionMode,
3128
+ mcpConfig,
3129
+ strictMcpConfig,
3130
+ disallowedTools,
3131
+ appendSystemPromptFile,
3132
+ thinking,
3133
+ thinkingDisplay,
3134
+ fastMode,
3135
+ cliVersion
3136
+ } = opts;
3137
+ const args = [
3138
+ "--print",
3139
+ "--output-format",
3140
+ "stream-json",
3141
+ "--input-format",
3142
+ "stream-json",
3143
+ "--include-partial-messages",
3144
+ "--verbose"
3145
+ ];
3146
+ if (model) {
3147
+ args.push("--model", model);
3148
+ }
3149
+ if (permissionMode) {
3150
+ args.push("--permission-mode", permissionMode);
3151
+ }
3152
+ if (includeSessionId) {
3153
+ const sessionId = claudeSessions.get(sessionKey2);
3154
+ if (sessionId && !activeProcesses.has(sessionKey2)) {
3155
+ args.push("--resume", sessionId);
3156
+ }
3157
+ }
3158
+ if (mcpConfig) {
3159
+ const configs = Array.isArray(mcpConfig) ? mcpConfig : [mcpConfig];
3160
+ const filtered = configs.filter((c) => typeof c === "string" && c.length > 0);
3161
+ if (filtered.length > 0) {
3162
+ args.push("--mcp-config", ...filtered);
3163
+ }
3164
+ }
3165
+ if (strictMcpConfig) {
3166
+ args.push("--strict-mcp-config");
3167
+ }
3168
+ if (disallowedTools && disallowedTools.length > 0) {
3169
+ args.push("--disallowedTools", ...disallowedTools);
3170
+ }
3171
+ if (thinking && cliSupportsThinking(cliVersion ?? null)) {
3172
+ args.push("--thinking", thinking);
3173
+ }
3174
+ if (thinkingDisplay && cliSupportsThinkingDisplay(cliVersion ?? null)) {
3175
+ args.push("--thinking-display", thinkingDisplay);
3176
+ }
3177
+ if (appendSystemPromptFile) {
3178
+ args.push("--append-system-prompt-file", appendSystemPromptFile);
3179
+ }
3180
+ if (fastMode && cliSupportsFastMode(cliVersion ?? null)) {
3181
+ args.push("--settings", JSON.stringify({ fastMode: true }));
3182
+ }
3183
+ if (skipPermissions) {
3184
+ args.push("--dangerously-skip-permissions");
3185
+ }
3186
+ return args;
3187
+ }
3188
+ function sessionKey(cwd, modelId) {
3189
+ return `${cwd}::${modelId}`;
3190
+ }
3191
+
3192
+ // src/claude-session-wrapper.ts
3193
+ import { EventEmitter as EventEmitter4 } from "events";
3194
+ import { unlink as unlink2 } from "fs/promises";
3195
+
3196
+ // src/claude-session-bun.ts
3197
+ import * as os3 from "os";
3198
+ import * as fs4 from "fs";
3199
+ import * as path5 from "path";
3200
+ import { execFileSync } from "child_process";
3201
+ import { randomUUID as randomUUID3 } from "crypto";
3202
+ function resolveClaude(cmd = "claude") {
3203
+ if (path5.isAbsolute(cmd) && fs4.existsSync(cmd)) return cmd;
3204
+ const viaBun = Bun.which(cmd);
3205
+ if (viaBun) return viaBun;
3206
+ const isWin = os3.platform() === "win32";
3207
+ try {
3208
+ const out = execFileSync(isWin ? "where" : "which", [cmd], {
3209
+ encoding: "utf8",
3210
+ stdio: ["ignore", "pipe", "ignore"]
2844
3211
  });
2845
- res.end();
3212
+ const first = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).find((p) => fs4.existsSync(p));
3213
+ if (first) return first;
3214
+ } catch {
2846
3215
  }
2847
- const server2 = createServer(async (req, res) => {
2848
- if (req.method !== "POST" || !req.url?.startsWith("/mcp")) {
2849
- reject(req, res, 404, "not a POST to /mcp");
2850
- return;
2851
- }
2852
- if (req.headers.host !== boundAuthority) {
2853
- reject(req, res, 403, "host header is not the bound authority");
2854
- return;
2855
- }
2856
- if (req.headers.origin !== void 0) {
2857
- reject(req, res, 403, "origin header present");
2858
- return;
2859
- }
2860
- const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
2861
- if (contentType !== "application/json") {
2862
- reject(req, res, 415, "content-type is not application/json");
2863
- return;
2864
- }
2865
- if (!authOk(req)) {
2866
- reject(req, res, 401, "missing or invalid bearer token");
2867
- return;
3216
+ throw new Error(`Could not resolve command on PATH: ${cmd}`);
3217
+ }
3218
+ function encodeCwd(cwd) {
3219
+ return path5.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
3220
+ }
3221
+ var TERMINAL_STOP = /* @__PURE__ */ new Set(["end_turn", "stop_sequence", "max_tokens"]);
3222
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
3223
+ function resolveConfigDir(configDir) {
3224
+ const value = configDir ?? process.env.CLAUDE_CONFIG_DIR;
3225
+ if (!value) return path5.join(os3.homedir(), ".claude");
3226
+ if (value === "~") return os3.homedir();
3227
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
3228
+ return path5.join(os3.homedir(), value.slice(2));
3229
+ }
3230
+ return path5.resolve(value);
3231
+ }
3232
+ var ClaudeSession = class {
3233
+ sessionId;
3234
+ cwd;
3235
+ configDir;
3236
+ jsonlPath;
3237
+ raw = "";
3238
+ proc = null;
3239
+ cursor = 0;
3240
+ // index into transcript split('\n')
3241
+ lastDataAt = 0;
3242
+ exited = false;
3243
+ exitCode = null;
3244
+ aborted = false;
3245
+ signal;
3246
+ o;
3247
+ constructor(opts = {}) {
3248
+ this.cwd = path5.resolve(opts.cwd ?? process.cwd());
3249
+ this.configDir = resolveConfigDir(opts.configDir);
3250
+ this.signal = opts.signal;
3251
+ this.sessionId = randomUUID3();
3252
+ this.jsonlPath = path5.join(
3253
+ this.configDir,
3254
+ "projects",
3255
+ encodeCwd(this.cwd),
3256
+ `${this.sessionId}.jsonl`
3257
+ );
3258
+ this.o = {
3259
+ cwd: this.cwd,
3260
+ cliPath: opts.cliPath,
3261
+ configDir: this.configDir,
3262
+ model: opts.model,
3263
+ settingSources: opts.settingSources,
3264
+ extraArgs: opts.extraArgs ?? [],
3265
+ ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey,
3266
+ effort: opts.effort,
3267
+ cols: opts.cols ?? 200,
3268
+ rows: opts.rows ?? 50,
3269
+ bootMinMs: opts.bootMinMs ?? 3e3,
3270
+ bootQuietMs: opts.bootQuietMs ?? 1500,
3271
+ bootMaxMs: opts.bootMaxMs ?? 25e3,
3272
+ pollMs: opts.pollMs ?? 250,
3273
+ // Agentic turns (tool loops) routinely run for many minutes; a short
3274
+ // cap would surface as a mid-task error result. 30 min mirrors the
3275
+ // proxy-tool ceiling rather than a chat-reply expectation.
3276
+ turnTimeoutMs: opts.turnTimeoutMs ?? 18e5,
3277
+ bracketedPaste: opts.bracketedPaste ?? true,
3278
+ submitMinMs: opts.submitMinMs ?? 200,
3279
+ submitConfirmMs: opts.submitConfirmMs ?? 1500,
3280
+ submitMaxRetries: opts.submitMaxRetries ?? 8,
3281
+ debug: opts.debug ?? false
3282
+ };
3283
+ }
3284
+ async start() {
3285
+ if (this.signal?.aborted) throw new Error("aborted before start");
3286
+ this.signal?.addEventListener(
3287
+ "abort",
3288
+ () => {
3289
+ this.aborted = true;
3290
+ this.dispose();
3291
+ },
3292
+ { once: true }
3293
+ );
3294
+ const claude = resolveClaude(this.o.cliPath ?? "claude");
3295
+ const args = ["--session-id", this.sessionId];
3296
+ if (this.o.model) args.push("--model", this.o.model);
3297
+ if (this.o.settingSources !== null && this.o.settingSources !== void 0) {
3298
+ args.push("--setting-sources", this.o.settingSources);
2868
3299
  }
2869
- let requestId = null;
2870
- let requestMethod = null;
2871
- try {
2872
- const body = await readBody(req);
2873
- const request = JSON.parse(body);
2874
- requestId = request?.id ?? null;
2875
- requestMethod = typeof request?.method === "string" ? request.method : null;
2876
- if (request?.jsonrpc !== "2.0" || typeof request.method !== "string") {
2877
- writeJson(res, {
2878
- jsonrpc: "2.0",
2879
- id: requestId,
2880
- error: { code: -32600, message: "Invalid request" }
2881
- });
2882
- return;
2883
- }
2884
- log.debug("proxy-mcp request", {
2885
- method: request.method,
2886
- id: request.id
2887
- });
2888
- if (request.method === "initialize") {
2889
- writeJson(res, {
2890
- jsonrpc: "2.0",
2891
- id: requestId,
2892
- result: {
2893
- protocolVersion: PROTOCOL_VERSION,
2894
- capabilities: { tools: {} },
2895
- serverInfo: {
2896
- name: SERVER_NAME,
2897
- version: "0.1.0"
2898
- }
2899
- }
2900
- });
2901
- return;
2902
- }
2903
- if (request.method === "notifications/initialized") {
2904
- res.statusCode = 204;
2905
- res.end();
2906
- return;
2907
- }
2908
- if (request.method === "tools/list") {
2909
- writeJson(res, {
2910
- jsonrpc: "2.0",
2911
- id: requestId,
2912
- result: {
2913
- tools: tools.map((t) => ({
2914
- name: t.name,
2915
- description: t.description,
2916
- inputSchema: t.inputSchema
2917
- }))
2918
- }
2919
- });
2920
- return;
2921
- }
2922
- if (request.method === "tools/call") {
2923
- const params = request.params ?? {};
2924
- const toolName = String(params.name ?? "");
2925
- const input = params.arguments ?? {};
2926
- if (!tools.some((t) => t.name === toolName)) {
2927
- writeJson(res, {
2928
- jsonrpc: "2.0",
2929
- id: requestId,
2930
- result: {
2931
- content: [{ type: "text", text: `Unknown proxy tool: ${toolName}` }],
2932
- isError: true
2933
- }
2934
- });
2935
- return;
2936
- }
2937
- const interceptor = interceptors?.get(toolName);
2938
- if (interceptor) {
2939
- let intercepted;
2940
- try {
2941
- intercepted = await interceptor(input);
2942
- } catch (interceptorError) {
2943
- const message = interceptorError instanceof Error ? interceptorError.message : String(interceptorError);
2944
- log.warn("proxy-mcp interceptor failed", { toolName, error: message });
2945
- intercepted = { kind: "error", message };
2946
- }
2947
- writeToolCallResult(res, requestId, intercepted);
2948
- return;
3300
+ if (this.o.extraArgs && this.o.extraArgs.length) args.push(...this.o.extraArgs);
3301
+ if (this.o.debug)
3302
+ process.stderr.write(`[session] spawn: ${claude} ${args.join(" ")}
3303
+ `);
3304
+ this.lastDataAt = Date.now();
3305
+ this.proc = Bun.spawn([claude, ...args], {
3306
+ cwd: this.cwd,
3307
+ env: {
3308
+ ...process.env,
3309
+ CLAUDE_CONFIG_DIR: this.o.configDir,
3310
+ TERM: "xterm-256color",
3311
+ ...this.o.ignoreAnthropicApiKey ? { ANTHROPIC_API_KEY: void 0, ANTHROPIC_AUTH_TOKEN: void 0 } : {},
3312
+ ...this.o.effort ? { CLAUDE_CODE_EFFORT_LEVEL: this.o.effort } : {}
3313
+ },
3314
+ terminal: {
3315
+ cols: this.o.cols,
3316
+ rows: this.o.rows,
3317
+ data: (_term, d) => {
3318
+ this.lastDataAt = Date.now();
3319
+ const chunk = Buffer.from(d).toString("utf8");
3320
+ this.raw += chunk;
3321
+ if (this.o.debug) process.stdout.write(chunk);
2949
3322
  }
2950
- const callId = crypto2.randomUUID();
2951
- log.info("proxy-mcp tool call received", {
2952
- callId,
2953
- toolName,
2954
- hasInput: input != null
2955
- });
2956
- let timer = null;
2957
- const result = await new Promise(
2958
- (resolve4, reject2) => {
2959
- const entry = {
2960
- id: callId,
2961
- toolName,
2962
- input,
2963
- resolve: resolve4,
2964
- reject: reject2
2965
- };
2966
- pending.set(callId, entry);
2967
- const deadlineMs = resolveProxyCallTimeoutMs(
2968
- toolName,
2969
- input,
2970
- timeoutOverrides
2971
- );
2972
- timer = setTimeout(() => {
2973
- if (!pending.has(callId)) return;
2974
- pending.delete(callId);
2975
- log.notice("proxy-mcp tool call timed out", {
2976
- callId,
2977
- toolName,
2978
- deadlineMs
2979
- });
2980
- reject2(buildProxyTimeoutError(toolName, deadlineMs));
2981
- }, deadlineMs);
2982
- calls.emit("call", entry);
2983
- }
2984
- ).finally(() => {
2985
- if (timer) clearTimeout(timer);
2986
- pending.delete(callId);
2987
- });
2988
- writeToolCallResult(res, requestId, result);
2989
- return;
2990
3323
  }
2991
- writeJson(res, {
2992
- jsonrpc: "2.0",
2993
- id: requestId,
2994
- error: { code: -32601, message: `Unknown method: ${request.method}` }
2995
- });
2996
- } catch (error) {
2997
- const errorMessage = error instanceof Error ? error.message : String(error);
2998
- const logFn = isExpectedCleanupError(errorMessage) ? log.notice : log.warn;
2999
- logFn("proxy-mcp error handling request", {
3000
- error: errorMessage
3001
- });
3002
- if (requestMethod === "tools/call") {
3324
+ });
3325
+ this.proc.exited.then((code) => {
3326
+ this.exitCode = typeof code === "number" ? code : null;
3327
+ this.exited = true;
3328
+ this.proc = null;
3329
+ }).catch(() => {
3330
+ this.exited = true;
3331
+ this.proc = null;
3332
+ });
3333
+ await this.waitForBoot();
3334
+ this.cursor = this.lineCount();
3335
+ }
3336
+ /** Wait until the TUI has been quiet for bootQuietMs (Ink ready), bounded by
3337
+ * bootMinMs..bootMaxMs. */
3338
+ async waitForBoot() {
3339
+ const start = Date.now();
3340
+ while (Date.now() - start < this.o.bootMaxMs) {
3341
+ await delay(150);
3342
+ if (this.aborted) throw new Error("aborted during boot");
3343
+ if (this.exited) {
3344
+ throw new Error(this.failureMessage("claude exited during boot", true));
3345
+ }
3346
+ const elapsed = Date.now() - start;
3347
+ const sinceData = Date.now() - this.lastDataAt;
3348
+ if (elapsed >= this.o.bootMinMs && sinceData >= this.o.bootQuietMs) return;
3349
+ }
3350
+ }
3351
+ /** Submit the freshly-injected prompt and confirm the turn was actually
3352
+ * accepted. A large bracketed paste collapses into a "[Pasted text]"
3353
+ * placeholder; an Enter sent while claude is still ingesting the paste is
3354
+ * silently dropped, so a single fixed-delay Enter races the paste and can
3355
+ * leave the prompt sitting unsubmitted (→ hang until turnTimeoutMs). Send
3356
+ * Enter, then poll for transcript growth past the cursor (the turn's records
3357
+ * are written on acceptance); resend Enter until accepted or the retry
3358
+ * budget is spent. Polling growth (not a blind delay) also stops us from
3359
+ * sending a stray Enter once the turn is in flight. */
3360
+ async submitTurn() {
3361
+ await delay(this.o.submitMinMs);
3362
+ for (let attempt = 0; attempt < this.o.submitMaxRetries; attempt++) {
3363
+ if (this.aborted || this.exited || !this.proc) return;
3364
+ this.proc.terminal.write("\r");
3365
+ const until = Date.now() + this.o.submitConfirmMs;
3366
+ while (Date.now() < until) {
3367
+ await delay(80);
3368
+ if (this.aborted || this.exited) return;
3369
+ if (this.lineCount() > this.cursor) return;
3370
+ }
3371
+ }
3372
+ }
3373
+ readRawLines() {
3374
+ try {
3375
+ return fs4.readFileSync(this.jsonlPath, "utf8").split("\n");
3376
+ } catch {
3377
+ return [];
3378
+ }
3379
+ }
3380
+ /** Count of complete lines (split('\n') minus the trailing/partial element). */
3381
+ lineCount() {
3382
+ const lines = this.readRawLines();
3383
+ return lines.length > 0 ? lines.length - 1 : 0;
3384
+ }
3385
+ rawTail(max = 600) {
3386
+ const clean = this.raw.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").replace(/\s+/g, " ").trim();
3387
+ return clean.length > max ? clean.slice(-max) : clean;
3388
+ }
3389
+ failureMessage(reason, includeRaw = false) {
3390
+ const parts = [
3391
+ `${reason} (sessionId=${this.sessionId}, jsonlPath=${this.jsonlPath}, exitCode=${this.exitCode ?? "unknown"})`
3392
+ ];
3393
+ if (includeRaw) {
3394
+ const tail = this.rawTail();
3395
+ if (tail) parts.push(`terminalTail=${JSON.stringify(tail)}`);
3396
+ }
3397
+ return parts.join("; ");
3398
+ }
3399
+ /**
3400
+ * Inject a turn into the live session and return the assistant reply once a
3401
+ * terminal stop_reason is observed in the transcript.
3402
+ */
3403
+ async ask(prompt, perTurnTimeoutMs) {
3404
+ if (this.aborted) throw new Error("aborted");
3405
+ if (!this.proc || this.exited)
3406
+ throw new Error("session not started or already exited");
3407
+ const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
3408
+ const t0 = Date.now();
3409
+ if (this.o.bracketedPaste) {
3410
+ this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
3411
+ } else {
3412
+ this.proc.terminal.write(prompt);
3413
+ }
3414
+ await this.submitTurn();
3415
+ const collected = [];
3416
+ let lastUsage = null;
3417
+ let stopReason = null;
3418
+ const deadline = Date.now() + timeout;
3419
+ while (Date.now() < deadline) {
3420
+ await delay(this.o.pollMs);
3421
+ if (this.aborted) throw new Error("aborted mid-turn");
3422
+ const lines = this.readRawLines();
3423
+ const lastComplete = lines.length - 1;
3424
+ if (lastComplete <= this.cursor) {
3425
+ if (this.exited) throw new Error(this.failureMessage("claude exited mid-turn", true));
3426
+ continue;
3427
+ }
3428
+ for (let i = this.cursor; i < lastComplete; i++) {
3429
+ const s = lines[i];
3430
+ if (!s || !s.trim()) continue;
3431
+ let rec;
3003
3432
  try {
3004
- writeJson(res, {
3005
- jsonrpc: "2.0",
3006
- id: requestId,
3007
- result: {
3008
- content: [{ type: "text", text: errorMessage }],
3009
- isError: true
3010
- }
3011
- });
3433
+ rec = JSON.parse(s);
3012
3434
  } catch {
3013
- try {
3014
- res.statusCode = 500;
3015
- res.end();
3016
- } catch {
3017
- }
3435
+ continue;
3018
3436
  }
3019
- return;
3020
- }
3021
- try {
3022
- writeJson(res, {
3023
- jsonrpc: "2.0",
3024
- id: requestId,
3025
- error: {
3026
- code: -32603,
3027
- message: error instanceof Error ? error.message : "Internal error"
3437
+ if (rec.type === "assistant" && rec.message) {
3438
+ for (const b of rec.message.content ?? []) {
3439
+ if (b?.type === "text" && typeof b.text === "string")
3440
+ collected.push(b.text);
3441
+ }
3442
+ if (rec.message.usage) lastUsage = rec.message.usage;
3443
+ if (rec.message.stop_reason && TERMINAL_STOP.has(rec.message.stop_reason)) {
3444
+ stopReason = rec.message.stop_reason;
3028
3445
  }
3029
- });
3030
- } catch {
3031
- try {
3032
- res.statusCode = 500;
3033
- res.end();
3034
- } catch {
3035
3446
  }
3036
3447
  }
3448
+ this.cursor = lastComplete;
3449
+ if (stopReason) break;
3037
3450
  }
3038
- });
3039
- await new Promise((resolve4, reject2) => {
3040
- server2.once("error", reject2);
3041
- server2.listen(0, "127.0.0.1", () => {
3042
- server2.off("error", reject2);
3043
- resolve4();
3044
- });
3045
- });
3046
- const addr = server2.address();
3047
- if (!addr) {
3048
- server2.close();
3049
- throw new Error("Failed to bind proxy MCP server");
3050
- }
3051
- boundAuthority = `127.0.0.1:${addr.port}`;
3052
- const url = `http://${boundAuthority}/mcp`;
3053
- log.info("proxy-mcp server started", {
3054
- url,
3055
- tools: tools.map((t) => t.name)
3056
- });
3057
- let configFilePath = null;
3058
- const api = {
3059
- url,
3060
- serverName: SERVER_NAME,
3061
- tools,
3062
- authToken,
3063
- calls,
3064
- configPath() {
3065
- if (configFilePath) return configFilePath;
3066
- const body = JSON.stringify(
3067
- {
3068
- mcpServers: {
3069
- [SERVER_NAME]: {
3070
- type: "http",
3071
- url,
3072
- // Claude CLI replays these headers on every request to this
3073
- // server, which is what lets the handler above reject anyone
3074
- // who did not read this 0600 file.
3075
- headers: { Authorization: `Bearer ${authToken}` },
3076
- timeout: resolveProxyClientCeilingMs(timeoutOverrides)
3077
- }
3078
- }
3079
- },
3080
- null,
3081
- 2
3082
- );
3083
- const hash = crypto2.createHash("sha256").update(body).digest("hex").slice(0, 12);
3084
- const outPath = path4.join(
3085
- pluginTmpDir(),
3086
- `proxy-${hash}.json`
3451
+ if (!stopReason) {
3452
+ throw new Error(
3453
+ this.failureMessage(
3454
+ `turn timed out after ${timeout}ms (no terminal assistant record; collected ${collected.length} text block(s))`
3455
+ )
3087
3456
  );
3088
- fs4.writeFileSync(outPath, body, { encoding: "utf8", mode: 384 });
3089
- configFilePath = outPath;
3090
- return outPath;
3091
- },
3092
- async close() {
3093
- for (const entry of pending.values()) {
3094
- entry.reject(new Error(SERVER_CLOSED_MESSAGE));
3457
+ }
3458
+ const u = lastUsage ?? {};
3459
+ return {
3460
+ text: collected.join("\n").trim(),
3461
+ stopReason,
3462
+ usage: lastUsage,
3463
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
3464
+ cacheCreationTokens: u.cache_creation_input_tokens ?? 0,
3465
+ ephemeral1hTokens: u.cache_creation?.ephemeral_1h_input_tokens ?? 0,
3466
+ ephemeral5mTokens: u.cache_creation?.ephemeral_5m_input_tokens ?? 0,
3467
+ inputTokens: u.input_tokens ?? 0,
3468
+ outputTokens: u.output_tokens ?? 0,
3469
+ elapsedMs: Date.now() - t0
3470
+ };
3471
+ }
3472
+ /**
3473
+ * Like ask(), but instead of collecting the reply text it re-emits each NEW
3474
+ * raw JSONL transcript line via onLine (verbatim) until a terminal
3475
+ * stop_reason. Returns the terminal stop_reason + the last assistant usage.
3476
+ * Used by the opencode plugin transport shim, which feeds these raw lines
3477
+ * into the existing stream-json line handler unchanged.
3478
+ */
3479
+ async tailTurn(prompt, onLine, perTurnTimeoutMs) {
3480
+ if (this.aborted) throw new Error("aborted");
3481
+ if (!this.proc || this.exited)
3482
+ throw new Error("session not started or already exited");
3483
+ const timeout = perTurnTimeoutMs ?? this.o.turnTimeoutMs;
3484
+ if (this.o.bracketedPaste) {
3485
+ this.proc.terminal.write("\x1B[200~" + prompt + "\x1B[201~");
3486
+ } else {
3487
+ this.proc.terminal.write(prompt);
3488
+ }
3489
+ await this.submitTurn();
3490
+ let lastUsage = null;
3491
+ let totalOutput = 0;
3492
+ let stopReason = null;
3493
+ const deadline = Date.now() + timeout;
3494
+ while (Date.now() < deadline) {
3495
+ await delay(this.o.pollMs);
3496
+ if (this.aborted) throw new Error("aborted mid-turn");
3497
+ const lines = this.readRawLines();
3498
+ const lastComplete = lines.length - 1;
3499
+ if (lastComplete <= this.cursor) {
3500
+ if (this.exited) {
3501
+ throw new Error(this.failureMessage("claude exited mid-turn", true));
3502
+ }
3503
+ continue;
3095
3504
  }
3096
- pending.clear();
3097
- await new Promise((resolve4) => {
3098
- server2.close(() => resolve4());
3099
- });
3100
- if (configFilePath) {
3505
+ for (let i = this.cursor; i < lastComplete; i++) {
3506
+ const s = lines[i];
3507
+ if (!s || !s.trim()) continue;
3508
+ onLine(s);
3509
+ let rec;
3101
3510
  try {
3102
- fs4.unlinkSync(configFilePath);
3511
+ rec = JSON.parse(s);
3103
3512
  } catch {
3513
+ continue;
3514
+ }
3515
+ if (rec.type === "assistant" && rec.message) {
3516
+ if (rec.message.usage) {
3517
+ lastUsage = rec.message.usage;
3518
+ totalOutput += rec.message.usage.output_tokens ?? 0;
3519
+ }
3520
+ if (rec.message.stop_reason && TERMINAL_STOP.has(rec.message.stop_reason)) {
3521
+ stopReason = rec.message.stop_reason;
3522
+ }
3104
3523
  }
3105
- configFilePath = null;
3106
3524
  }
3525
+ this.cursor = lastComplete;
3526
+ if (stopReason) break;
3107
3527
  }
3108
- };
3109
- return api;
3110
- }
3111
- function disallowedToolFlags(tools) {
3112
- const nameMap = {
3113
- bash: ["Bash"],
3114
- read: ["Read"],
3115
- write: ["Write"],
3116
- edit: ["Edit", "MultiEdit"],
3117
- glob: ["Glob"],
3118
- grep: ["Grep"],
3119
- webfetch: ["WebFetch"],
3120
- task: ["Agent"],
3121
- // `question` disables Claude Code's built-in `AskUserQuestion` so the
3122
- // structured-questions path flows through opencode's native `question`
3123
- // tool instead same UI/permission/audit benefits as the other
3124
- // proxies. Without this, the model can call both and the two paths
3125
- // diverge (opencode's form vs the headless deny-and-render fallback).
3126
- question: ["AskUserQuestion"]
3127
- };
3128
- const out = [];
3129
- const seen = /* @__PURE__ */ new Set();
3130
- for (const t of tools) {
3131
- const mapped = nameMap[t.name.toLowerCase()];
3132
- if (!mapped) continue;
3133
- for (const claudeTool of mapped) {
3134
- if (seen.has(claudeTool)) continue;
3135
- seen.add(claudeTool);
3136
- out.push(claudeTool);
3528
+ let usage = lastUsage;
3529
+ if (lastUsage) {
3530
+ usage = { ...lastUsage, output_tokens: totalOutput };
3531
+ if (Array.isArray(lastUsage.iterations) && lastUsage.iterations.length > 0) {
3532
+ const iters = lastUsage.iterations.map((it) => ({ ...it }));
3533
+ iters[iters.length - 1] = {
3534
+ ...iters[iters.length - 1],
3535
+ output_tokens: totalOutput
3536
+ };
3537
+ usage.iterations = iters;
3538
+ }
3539
+ }
3540
+ if (!stopReason) {
3541
+ throw new Error(
3542
+ this.failureMessage(
3543
+ `turn timed out after ${timeout}ms (no terminal assistant record)`
3544
+ )
3545
+ );
3137
3546
  }
3547
+ return { stopReason, usage };
3138
3548
  }
3139
- return out;
3140
- }
3141
- function resolveDisallowedTools(options) {
3142
- const out = [];
3143
- const seen = /* @__PURE__ */ new Set();
3144
- const push = (name) => {
3145
- const trimmed = name.trim();
3146
- if (!trimmed || seen.has(trimmed)) return;
3147
- seen.add(trimmed);
3148
- out.push(trimmed);
3149
- };
3150
- for (const name of disallowedToolFlags(options.proxyTools ?? [])) push(name);
3151
- for (const name of options.extraDisallowedTools ?? []) push(String(name));
3152
- if (options.disableWebSearch) push("WebSearch");
3153
- return out;
3154
- }
3155
- function readBody(req) {
3156
- return new Promise((resolve4, reject) => {
3157
- const chunks = [];
3158
- req.on("data", (chunk) => chunks.push(chunk));
3159
- req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
3160
- req.on("error", reject);
3161
- });
3162
- }
3163
- function writeToolCallResult(res, requestId, result) {
3164
- const text = result.kind === "error" ? result.message : result.text;
3165
- const isError = result.kind === "error" || result.isError === true;
3166
- writeJson(res, {
3167
- jsonrpc: "2.0",
3168
- id: requestId ?? null,
3169
- result: {
3170
- content: [{ type: "text", text }],
3171
- isError
3549
+ dispose() {
3550
+ if (this.proc) {
3551
+ try {
3552
+ this.proc.terminal.write("");
3553
+ } catch {
3554
+ }
3555
+ try {
3556
+ this.proc.kill();
3557
+ } catch {
3558
+ }
3559
+ try {
3560
+ this.proc.terminal.close();
3561
+ } catch {
3562
+ }
3172
3563
  }
3173
- });
3174
- }
3175
- function writeJson(res, body) {
3176
- const payload = JSON.stringify(body);
3177
- res.statusCode = 200;
3178
- res.setHeader("Content-Type", "application/json");
3179
- res.setHeader("Content-Length", Buffer.byteLength(payload).toString());
3180
- res.end(payload);
3181
- }
3564
+ this.proc = null;
3565
+ }
3566
+ };
3182
3567
 
3183
- // src/proxy-broker.ts
3184
- import { EventEmitter as EventEmitter4 } from "events";
3185
- var pendingByCallId = /* @__PURE__ */ new Map();
3186
- var callIdsBySession = /* @__PURE__ */ new Map();
3187
- var emitter = new EventEmitter4();
3188
- function eventName(sessionKey2) {
3189
- return `pending:${sessionKey2}`;
3190
- }
3191
- function indexAdd(sessionKey2, callId) {
3192
- let s = callIdsBySession.get(sessionKey2);
3193
- if (!s) {
3194
- s = /* @__PURE__ */ new Set();
3195
- callIdsBySession.set(sessionKey2, s);
3568
+ // src/claude-session-wrapper.ts
3569
+ function decodeUserEnvelope(chunk) {
3570
+ let parsed;
3571
+ try {
3572
+ parsed = JSON.parse(chunk);
3573
+ } catch {
3574
+ return chunk;
3196
3575
  }
3197
- s.add(callId);
3198
- }
3199
- function indexRemove(sessionKey2, callId) {
3200
- const s = callIdsBySession.get(sessionKey2);
3201
- if (!s) return;
3202
- s.delete(callId);
3203
- if (s.size === 0) callIdsBySession.delete(sessionKey2);
3204
- }
3205
- function onPendingProxyCall(sessionKey2, handler) {
3206
- const name = eventName(sessionKey2);
3207
- emitter.on(name, handler);
3208
- return () => emitter.off(name, handler);
3209
- }
3210
- function queuePendingProxyCall(sessionKey2, call, timeoutOverrides) {
3211
- const previous = pendingByCallId.get(call.id);
3212
- if (previous) {
3213
- clearTimeout(previous.timer);
3214
- previous.reject(
3215
- new Error(`Replaced pending proxy call ${call.id} with a fresh one`)
3216
- );
3217
- pendingByCallId.delete(call.id);
3218
- indexRemove(previous.sessionKey, call.id);
3576
+ if (!parsed || parsed.type !== "user" || !parsed.message) return chunk;
3577
+ const content = parsed.message.content;
3578
+ if (typeof content === "string") return content;
3579
+ if (!Array.isArray(content)) return chunk;
3580
+ const parts = [];
3581
+ let dropped = 0;
3582
+ for (const block of content) {
3583
+ if (block?.type === "text" && typeof block.text === "string") {
3584
+ parts.push(block.text);
3585
+ } else if (block?.type === "tool_result") {
3586
+ const v = block.content;
3587
+ const text = typeof v === "string" ? v : Array.isArray(v) ? v.map((i) => i?.type === "text" ? i.text : "").filter(Boolean).join("\n") : "";
3588
+ parts.push(
3589
+ `[Tool result${block.tool_use_id ? ` ${block.tool_use_id}` : ""}]
3590
+ ${text}`
3591
+ );
3592
+ } else {
3593
+ dropped++;
3594
+ }
3219
3595
  }
3220
- const deadlineMs = resolveProxyCallTimeoutMs(
3221
- call.toolName,
3222
- call.input,
3223
- timeoutOverrides
3224
- );
3225
- const timer = setTimeout(() => {
3226
- const current = pendingByCallId.get(call.id);
3227
- if (!current) return;
3228
- pendingByCallId.delete(call.id);
3229
- indexRemove(current.sessionKey, call.id);
3230
- current.reject(buildProxyTimeoutError(call.toolName, deadlineMs));
3231
- log.notice("timed out pending proxy call", {
3232
- sessionKey: current.sessionKey,
3233
- toolCallId: call.id,
3234
- toolName: call.toolName,
3235
- deadlineMs
3596
+ if (dropped > 0) {
3597
+ log.warn("interactive transport dropped non-text content blocks", {
3598
+ dropped
3236
3599
  });
3237
- }, deadlineMs);
3238
- const pending = {
3239
- sessionKey: sessionKey2,
3240
- toolCallId: call.id,
3241
- toolName: call.toolName,
3242
- input: call.input,
3243
- createdAt: Date.now(),
3244
- timer,
3245
- resolve: call.resolve,
3246
- reject: call.reject
3247
- };
3248
- pendingByCallId.set(call.id, pending);
3249
- indexAdd(sessionKey2, call.id);
3250
- emitter.emit(eventName(sessionKey2), pending);
3251
- log.info("queued pending proxy call", {
3252
- sessionKey: sessionKey2,
3253
- toolCallId: call.id,
3254
- toolName: call.toolName
3255
- });
3256
- return pending;
3600
+ }
3601
+ return parts.join("\n\n");
3257
3602
  }
3258
- function getPendingProxyCalls(sessionKey2) {
3259
- const s = callIdsBySession.get(sessionKey2);
3260
- if (!s || s.size === 0) return [];
3261
- const out = [];
3262
- for (const id of s) {
3263
- const p = pendingByCallId.get(id);
3264
- if (p) out.push(p);
3603
+ function spawnInteractiveProcess(opts) {
3604
+ const extraArgs = [];
3605
+ if (opts.mcpConfigPaths && opts.mcpConfigPaths.length > 0) {
3606
+ extraArgs.push(
3607
+ "--mcp-config",
3608
+ ...opts.mcpConfigPaths,
3609
+ "--strict-mcp-config"
3610
+ );
3611
+ }
3612
+ const flagSettings = {};
3613
+ if (opts.permissionsAllow && opts.permissionsAllow.length > 0) {
3614
+ flagSettings.permissions = { allow: opts.permissionsAllow };
3265
3615
  }
3266
- return out;
3267
- }
3268
- function resolvePendingProxyCallById(toolCallId, result) {
3269
- const pending = pendingByCallId.get(toolCallId);
3270
- if (!pending) return false;
3271
- pendingByCallId.delete(toolCallId);
3272
- indexRemove(pending.sessionKey, toolCallId);
3273
- clearTimeout(pending.timer);
3274
- pending.resolve(result);
3275
- log.info("resolved pending proxy call", {
3276
- sessionKey: pending.sessionKey,
3277
- toolCallId: pending.toolCallId,
3278
- toolName: pending.toolName
3616
+ if (opts.fastMode) {
3617
+ flagSettings.fastMode = true;
3618
+ }
3619
+ if (Object.keys(flagSettings).length > 0) {
3620
+ extraArgs.push("--settings", JSON.stringify(flagSettings));
3621
+ }
3622
+ if (opts.permissionMode === "bypassPermissions") {
3623
+ log.warn(
3624
+ "interactive permissionMode bypassPermissions ignored: Claude Code prompts for confirmation in the TUI"
3625
+ );
3626
+ } else if (opts.permissionMode) {
3627
+ extraArgs.push("--permission-mode", opts.permissionMode);
3628
+ }
3629
+ if (opts.systemPromptFile) {
3630
+ extraArgs.push("--append-system-prompt-file", opts.systemPromptFile);
3631
+ }
3632
+ const session = new ClaudeSession({
3633
+ cwd: opts.cwd,
3634
+ cliPath: opts.cliPath,
3635
+ configDir: opts.configDir,
3636
+ model: opts.model,
3637
+ // Default null = normal CLAUDE.md + settings load, matching what the
3638
+ // headless spawn does. "" (skip everything) is for fast e2e runs only.
3639
+ settingSources: opts.settingSources === void 0 ? null : opts.settingSources,
3640
+ extraArgs,
3641
+ ignoreAnthropicApiKey: opts.ignoreAnthropicApiKey,
3642
+ effort: opts.effort ? cliEffortLevel(opts.effort) : void 0
3279
3643
  });
3280
- return true;
3281
- }
3282
- function rejectPendingProxyCallById(toolCallId, error) {
3283
- const pending = pendingByCallId.get(toolCallId);
3284
- if (!pending) return false;
3285
- pendingByCallId.delete(toolCallId);
3286
- indexRemove(pending.sessionKey, toolCallId);
3287
- clearTimeout(pending.timer);
3288
- pending.reject(error);
3289
- log.notice("rejected pending proxy call", {
3290
- sessionKey: pending.sessionKey,
3291
- toolCallId: pending.toolCallId,
3292
- toolName: pending.toolName,
3293
- error: error.message
3644
+ log.info("prepared interactive claude session", {
3645
+ cwd: opts.cwd,
3646
+ cliPath: opts.cliPath ?? "claude",
3647
+ configDir: session.configDir,
3648
+ model: opts.model,
3649
+ effort: opts.effort,
3650
+ sessionId: session.sessionId,
3651
+ jsonlPath: session.jsonlPath
3294
3652
  });
3295
- return true;
3296
- }
3297
- function rejectAllPendingProxyCallsForSession(sessionKey2, error) {
3298
- const s = callIdsBySession.get(sessionKey2);
3299
- if (!s) return 0;
3300
- const ids = [...s];
3301
- let count = 0;
3302
- for (const id of ids) {
3303
- if (rejectPendingProxyCallById(id, error)) count++;
3304
- }
3305
- return count;
3653
+ const lineEmitter = new EventEmitter4();
3654
+ const errorHandlers = /* @__PURE__ */ new Set();
3655
+ let startPromise = null;
3656
+ const ensureStarted = () => {
3657
+ if (!startPromise) startPromise = session.start();
3658
+ return startPromise;
3659
+ };
3660
+ const emitResult = (subtype, isError, result, usage) => {
3661
+ lineEmitter.emit(
3662
+ "line",
3663
+ JSON.stringify({
3664
+ type: "result",
3665
+ subtype,
3666
+ is_error: isError,
3667
+ result,
3668
+ session_id: session.sessionId,
3669
+ usage: usage ?? {},
3670
+ total_cost_usd: null,
3671
+ duration_ms: 0
3672
+ })
3673
+ );
3674
+ };
3675
+ const runTurn = (userMsg) => {
3676
+ void (async () => {
3677
+ try {
3678
+ await ensureStarted();
3679
+ const { stopReason, usage } = await session.tailTurn(userMsg, (raw) => {
3680
+ lineEmitter.emit("line", raw);
3681
+ });
3682
+ const timedOut = !stopReason;
3683
+ emitResult(
3684
+ timedOut ? "error_during_execution" : stopReason,
3685
+ timedOut,
3686
+ timedOut ? "Interactive transport: the turn ended without a terminal stop_reason (turn timeout or claude exit). Output above may be incomplete." : void 0,
3687
+ usage
3688
+ );
3689
+ } catch (err) {
3690
+ const e = err instanceof Error ? err : new Error(String(err));
3691
+ log.error("interactive turn failed", { error: e.message });
3692
+ emitResult(
3693
+ "error_during_execution",
3694
+ true,
3695
+ `Interactive transport failed: ${e.message}`
3696
+ );
3697
+ if (errorHandlers.size > 0) {
3698
+ for (const h of errorHandlers) h(e);
3699
+ } else {
3700
+ lineEmitter.emit("close");
3701
+ }
3702
+ }
3703
+ })();
3704
+ };
3705
+ const proc = {
3706
+ stdin: {
3707
+ write(chunk) {
3708
+ const raw = typeof chunk === "string" && chunk.endsWith("\n") ? chunk.slice(0, -1) : chunk;
3709
+ runTurn(decodeUserEnvelope(raw));
3710
+ return true;
3711
+ },
3712
+ end() {
3713
+ }
3714
+ },
3715
+ stdout: null,
3716
+ stderr: null,
3717
+ pid: -1,
3718
+ killed: false,
3719
+ on(event, fn) {
3720
+ if (event === "error") errorHandlers.add(fn);
3721
+ return proc;
3722
+ },
3723
+ once() {
3724
+ return proc;
3725
+ },
3726
+ off(event, fn) {
3727
+ if (event === "error") errorHandlers.delete(fn);
3728
+ return proc;
3729
+ },
3730
+ kill() {
3731
+ try {
3732
+ session.dispose();
3733
+ } catch {
3734
+ }
3735
+ if (opts.systemPromptFile) {
3736
+ void unlink2(opts.systemPromptFile).catch(() => {
3737
+ });
3738
+ }
3739
+ proc.killed = true;
3740
+ return true;
3741
+ }
3742
+ };
3743
+ return {
3744
+ proc,
3745
+ lineEmitter,
3746
+ proxyServer: null,
3747
+ mcpHash: void 0,
3748
+ systemPromptFile: opts.systemPromptFile
3749
+ };
3306
3750
  }
3307
3751
 
3308
3752
  // src/claude-code-language-model.ts
3309
3753
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
3310
3754
  import { unlink as unlink3 } from "fs/promises";
3311
3755
  import { homedir as homedir4, tmpdir as tmpdir2 } from "os";
3312
- import { randomUUID as randomUUID3 } from "crypto";
3756
+ import { randomUUID as randomUUID4 } from "crypto";
3313
3757
  import { dirname as dirname3, join as join6 } from "path";
3314
3758
  var DEFAULT_COMPACTION_MODEL = "claude-haiku-4-5";
3315
3759
  function resolveCompactionModel(configured) {
@@ -3501,9 +3945,25 @@ function makeAutoContinueMessage() {
3501
3945
  }
3502
3946
  });
3503
3947
  }
3504
- function readPromptFileIfPresent(path7) {
3948
+ function makeLateProxyResultMessage(entries) {
3949
+ const sections = entries.map(({ call, result }) => {
3950
+ const failed = result.kind === "error" || result.isError === true;
3951
+ const body = result.kind === "error" ? result.message : result.text;
3952
+ return `Your earlier \`${call.toolName}\` tool call (id ${call.toolCallId}) has ${failed ? "failed" : "completed"}, but delivery or continuation was interrupted. Treat the following as its ${failed ? "error" : "result"} and continue from there; do not re-run it.
3953
+
3954
+ ${body}`;
3955
+ });
3956
+ return JSON.stringify({
3957
+ type: "user",
3958
+ message: {
3959
+ role: "user",
3960
+ content: [{ type: "text", text: sections.join("\n\n---\n\n") }]
3961
+ }
3962
+ });
3963
+ }
3964
+ function readPromptFileIfPresent(path8) {
3505
3965
  try {
3506
- const content = readFileSync3(path7, "utf8").trim();
3966
+ const content = readFileSync3(path8, "utf8").trim();
3507
3967
  return content || void 0;
3508
3968
  } catch {
3509
3969
  return void 0;
@@ -3606,10 +4066,10 @@ ${options.compressionSummary.trim()}`
3606
4066
  if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
3607
4067
  const content = parts.join("\n\n");
3608
4068
  if (!content) return void 0;
3609
- const path7 = join6(tmpdir2(), `opencode-cc-sys-${randomUUID3()}.md`);
4069
+ const path8 = join6(tmpdir2(), `opencode-cc-sys-${randomUUID4()}.md`);
3610
4070
  try {
3611
- writeFileSync3(path7, content, "utf8");
3612
- return path7;
4071
+ writeFileSync3(path8, content, "utf8");
4072
+ return path8;
3613
4073
  } catch (err) {
3614
4074
  log.warn("failed to write system prompt file", { error: String(err) });
3615
4075
  return void 0;
@@ -4186,11 +4646,26 @@ var ClaudeCodeLanguageModel = class {
4186
4646
  };
4187
4647
  }
4188
4648
  async doGenerate(options) {
4649
+ if (!this.isCompactionCall(options) && this.requestScope(options) !== "no-tools" && parseSideQuestion(options.prompt)) {
4650
+ return this.doGenerateViaStream(options);
4651
+ }
4189
4652
  const warnings = [];
4190
4653
  const cwd = resolveSpawnCwd(this.config.cwd);
4191
4654
  const scope = this.requestScope(options);
4192
4655
  const affinity = this.sessionAffinity(options);
4193
- const sk = sessionKey(cwd, `${this.modelId}::${scope}::${affinity}`);
4656
+ const effectiveModelId = resolveAgentModel(
4657
+ this.getOpencodeAgent(options.providerOptions),
4658
+ this.modelId
4659
+ );
4660
+ const reasoningEffort = resolveAgentEffort(
4661
+ this.getOpencodeAgent(options.providerOptions),
4662
+ this.getReasoningEffort(options.providerOptions)
4663
+ );
4664
+ const baseKey = sessionKey(
4665
+ cwd,
4666
+ `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify([this.config.provider, this.getOpencodeAgent(options.providerOptions) ?? null])}`
4667
+ );
4668
+ const sk = effortSessionKey(baseKey, reasoningEffort);
4194
4669
  const compactionMode = this.isCompactionCall(options);
4195
4670
  if (scope === "tools" && (this.resolvedProxyTools() || this.config.proxyOpencodeMcpTools !== false && this.config.bridgeOpencodeMcp !== false)) {
4196
4671
  return this.doGenerateViaStream(options);
@@ -4242,6 +4717,7 @@ var ClaudeCodeLanguageModel = class {
4242
4717
  warnings
4243
4718
  };
4244
4719
  }
4720
+ invalidateOtherEffortSessions(baseKey, reasoningEffort);
4245
4721
  const hasPriorConversation = options.prompt.filter((m) => m.role === "user" || m.role === "assistant").length > 1;
4246
4722
  if (!hasPriorConversation) {
4247
4723
  deleteClaudeSessionId(sk);
@@ -4250,8 +4726,7 @@ var ClaudeCodeLanguageModel = class {
4250
4726
  }
4251
4727
  const hasExistingSession = !!getClaudeSessionId(sk);
4252
4728
  const includeHistoryContext = !hasExistingSession && hasPriorConversation;
4253
- const reasoningEffort = this.getReasoningEffort(options.providerOptions);
4254
- const userMsg = consumeExitPlanModeQuestionResult(sk, options.prompt) ?? getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort);
4729
+ const userMsg = consumeExitPlanModeQuestionResult(sk, options.prompt) ?? getClaudeUserMessage(options.prompt, includeHistoryContext);
4255
4730
  const [runtimeStatus, cliVersion, planModeQuestionActive] = await Promise.all([
4256
4731
  getRuntimeMcpStatus(),
4257
4732
  detectCliVersion(this.config.cliPath),
@@ -4265,7 +4740,7 @@ var ClaudeCodeLanguageModel = class {
4265
4740
  // An existing summary still carries: it is this key's prior context.
4266
4741
  { compressEnabled: false, compressionSummary: getCompressionSummary(sk) }
4267
4742
  );
4268
- const { model: spawnModelId, fast: fastMode } = parseModelId(this.modelId);
4743
+ const { model: spawnModelId, fast: fastMode } = parseModelId(effectiveModelId);
4269
4744
  const cliArgs = buildCliArgs({
4270
4745
  sessionKey: sk,
4271
4746
  skipPermissions: this.config.skipPermissions !== false,
@@ -4282,7 +4757,8 @@ var ClaudeCodeLanguageModel = class {
4282
4757
  });
4283
4758
  log.info("doGenerate starting", {
4284
4759
  cwd,
4285
- model: this.modelId,
4760
+ model: effectiveModelId,
4761
+ requestedModel: this.modelId,
4286
4762
  textLength: userMsg.length,
4287
4763
  includeHistoryContext
4288
4764
  });
@@ -4292,7 +4768,8 @@ var ClaudeCodeLanguageModel = class {
4292
4768
  cwd,
4293
4769
  stdio: ["pipe", "pipe", "pipe"],
4294
4770
  env: claudeSpawnEnv({
4295
- ignoreAnthropicApiKey: this.config.ignoreAnthropicApiKey
4771
+ ignoreAnthropicApiKey: this.config.ignoreAnthropicApiKey,
4772
+ effort: reasoningEffort
4296
4773
  }),
4297
4774
  shell: process.platform === "win32"
4298
4775
  });
@@ -4568,9 +5045,20 @@ ${plan}
4568
5045
  const scope = this.requestScope(options);
4569
5046
  const affinity = this.sessionAffinity(options);
4570
5047
  const compactionMode = this.isCompactionCall(options);
4571
- const effectiveModelId = compactionMode ? this.resolveCompactionModel() : this.modelId;
5048
+ const effectiveModelId = compactionMode ? this.resolveCompactionModel() : resolveAgentModel(
5049
+ this.getOpencodeAgent(options.providerOptions),
5050
+ this.modelId
5051
+ );
4572
5052
  const { model: spawnModelId, fast: fastMode } = parseModelId(effectiveModelId);
4573
- const sk = compactionMode ? sessionKey(cwd, `${effectiveModelId}::compaction::${affinity}`) : sessionKey(cwd, `${this.modelId}::${scope}::${affinity}`);
5053
+ const reasoningEffort = compactionMode ? void 0 : resolveAgentEffort(
5054
+ this.getOpencodeAgent(options.providerOptions),
5055
+ this.getReasoningEffort(options.providerOptions)
5056
+ );
5057
+ const baseKey = sessionKey(
5058
+ cwd,
5059
+ `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify([this.config.provider, this.getOpencodeAgent(options.providerOptions) ?? null])}`
5060
+ );
5061
+ const sk = compactionMode ? sessionKey(cwd, `${effectiveModelId}::compaction::${affinity}`) : effortSessionKey(baseKey, reasoningEffort);
4574
5062
  const toUsage = this.toUsage.bind(this);
4575
5063
  const toFinishReason = this.toFinishReason.bind(this);
4576
5064
  const handleControlRequest = this.handleControlRequest.bind(this);
@@ -4578,6 +5066,45 @@ ${plan}
4578
5066
  const interactivePref = this.config.interactive ?? flagOn(process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT);
4579
5067
  const useInteractive = interactivePref && typeof globalThis.Bun?.Terminal === "function";
4580
5068
  const interactiveBypassRequested = this.config.interactiveBypass ?? flagOn(process.env.CLAUDE_CODE_INTERACTIVE_BYPASS);
5069
+ const aside = !compactionMode && scope !== "no-tools" ? parseSideQuestion(options.prompt) : null;
5070
+ if (aside) {
5071
+ const active = getActiveProcess(sk);
5072
+ const stream2 = new ReadableStream({
5073
+ async start(controller) {
5074
+ controller.enqueue({ type: "stream-start", warnings });
5075
+ try {
5076
+ if (aside.question && !active) {
5077
+ throw new Error("/btw needs an existing Claude Code session. Send a normal message with this model first.");
5078
+ }
5079
+ const answer = aside.question && active ? await requestSideQuestion(active, aside.question, {
5080
+ cliVersion: await detectCliVersion(cliPath),
5081
+ interactive: useInteractive,
5082
+ busy: getPendingProxyCalls(sk).length > 0 || !!active.pendingProxyCompletions?.size,
5083
+ abortSignal: options.abortSignal
5084
+ }) : { response: SIDE_QUESTION_USAGE, synthetic: true };
5085
+ const id = generateId();
5086
+ controller.enqueue({ type: "text-start", id });
5087
+ controller.enqueue({ type: "text-delta", id, delta: answer.response });
5088
+ controller.enqueue({ type: "text-end", id });
5089
+ controller.enqueue({
5090
+ type: "finish",
5091
+ finishReason: toFinishReason("stop"),
5092
+ usage: toUsage({}),
5093
+ providerMetadata: { "claude-code": { path: "side-question", synthetic: answer.synthetic, usageUnavailable: true } }
5094
+ });
5095
+ } catch (error) {
5096
+ controller.enqueue({ type: "error", error });
5097
+ } finally {
5098
+ controller.close();
5099
+ }
5100
+ }
5101
+ });
5102
+ return { stream: stream2, request: { body: { text: aside.question } } };
5103
+ }
5104
+ const existing = getActiveProcess(sk);
5105
+ if (existing && isSideQuestionPending(existing)) {
5106
+ throw new Error("Wait for /btw to finish before sending another message.");
5107
+ }
4581
5108
  if (scope === "no-tools" && !compactionMode) {
4582
5109
  log.info("doStream no-tools title stub", {
4583
5110
  compactionMode,
@@ -4633,6 +5160,7 @@ ${plan}
4633
5160
  });
4634
5161
  return { stream: stream2, request: { body: { text: "" } } };
4635
5162
  }
5163
+ if (!compactionMode) invalidateOtherEffortSessions(baseKey, reasoningEffort);
4636
5164
  const hasPriorConversation = options.prompt.filter((m) => m.role === "user" || m.role === "assistant").length > 1;
4637
5165
  if (!hasPriorConversation) {
4638
5166
  deleteClaudeSessionId(sk);
@@ -4642,12 +5170,11 @@ ${plan}
4642
5170
  const hasExistingSession = !!getClaudeSessionId(sk);
4643
5171
  const hasActiveProcess = !!getActiveProcess(sk);
4644
5172
  const includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation;
4645
- const reasoningEffort = this.getReasoningEffort(options.providerOptions);
4646
5173
  const exitPlanModeQuestionResult = compactionMode ? null : consumeExitPlanModeQuestionResult(sk, options.prompt);
4647
5174
  if (exitPlanModeQuestionResult) {
4648
5175
  log.info("sending plan approval decision to claude", { sk });
4649
5176
  }
4650
- const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(options.prompt, includeHistoryContext, reasoningEffort, {
5177
+ const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(options.prompt, includeHistoryContext, {
4651
5178
  compactionMode
4652
5179
  });
4653
5180
  const resolvedProxy = compactionMode ? null : this.resolvedProxyTools();
@@ -4770,7 +5297,8 @@ ${plan}
4770
5297
  mcpConfigPaths: mcp.paths,
4771
5298
  permissionsAllow: allow,
4772
5299
  systemPromptFile,
4773
- ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey
5300
+ ignoreAnthropicApiKey: self.config.ignoreAnthropicApiKey,
5301
+ effort: reasoningEffort
4774
5302
  });
4775
5303
  ap.mcpHash = mcp.bridgedHash;
4776
5304
  setActiveProcess(sk, ap);
@@ -4909,7 +5437,8 @@ ${plan}
4909
5437
  spawnProxyServer,
4910
5438
  spawnMcpHash,
4911
5439
  spawnSystemPromptFile,
4912
- self.config.ignoreAnthropicApiKey
5440
+ self.config.ignoreAnthropicApiKey,
5441
+ reasoningEffort
4913
5442
  );
4914
5443
  proc = ap.proc;
4915
5444
  lineEmitter = ap.lineEmitter;
@@ -4939,10 +5468,13 @@ ${plan}
4939
5468
  let hadThinkingTextFromStream = false;
4940
5469
  let turnCompleted = false;
4941
5470
  let controllerClosed = false;
5471
+ let unattendedTurnEnded = false;
5472
+ let watchdogMessage = userMsg;
4942
5473
  let pendingProxyUnsubscribe = null;
4943
5474
  let resultFallbackTimer = null;
4944
5475
  let pendingResultCompletion = null;
4945
5476
  let hasReceivedContent = false;
5477
+ let hasReceivedProgress = false;
4946
5478
  let visibleTextSinceContinue = "";
4947
5479
  let lastVisibleTextSinceContinue = "";
4948
5480
  let hadReasoningSinceContinue = false;
@@ -4963,7 +5495,7 @@ ${plan}
4963
5495
  };
4964
5496
  const startResultFallback = (delayMs = 6e4) => {
4965
5497
  clearFallbackTimer();
4966
- if (!hasReceivedContent || controllerClosed) return;
5498
+ if (!hasReceivedContent && !hasReceivedProgress || controllerClosed) return;
4967
5499
  resultFallbackTimer = setTimeout(() => {
4968
5500
  if (controllerClosed) return;
4969
5501
  log.warn("result fallback timer fired \u2014 closing stream without result event", {
@@ -4987,7 +5519,7 @@ ${plan}
4987
5519
  };
4988
5520
  const onStartWatchdogFire = () => {
4989
5521
  startWatchdog = null;
4990
- if (controllerClosed || hasReceivedContent) return;
5522
+ if (controllerClosed || hasReceivedContent || hasReceivedProgress) return;
4991
5523
  if (respawnAttempted) {
4992
5524
  log.error(
4993
5525
  "claude process still silent after respawn; ending turn",
@@ -5050,25 +5582,46 @@ ${plan}
5050
5582
  lineEmitter.on("close", closeHandler);
5051
5583
  proc.on("error", procErrorHandler);
5052
5584
  try {
5053
- proc.stdin?.write(userMsg + "\n");
5585
+ if (!deliverPendingCompletions(true)) proc.stdin?.write(watchdogMessage + "\n");
5054
5586
  log.debug("re-sent user message after respawn", {
5055
- textLength: userMsg.length
5587
+ textLength: watchdogMessage.length
5056
5588
  });
5057
5589
  } catch (err) {
5058
5590
  log.error("failed to re-send envelope after respawn", {
5059
5591
  error: err instanceof Error ? err.message : String(err)
5060
5592
  });
5061
5593
  }
5062
- startWatchdog = setTimeout(
5063
- onStartWatchdogFire,
5064
- START_WATCHDOG_MS
5065
- );
5594
+ armStartWatchdog();
5066
5595
  };
5067
5596
  const armStartWatchdog = () => {
5068
5597
  clearStartWatchdog();
5069
5598
  if (controllerClosed) return;
5070
5599
  startWatchdog = setTimeout(onStartWatchdogFire, START_WATCHDOG_MS);
5071
5600
  };
5601
+ const deliverPendingCompletions = (force = false) => {
5602
+ const pending = activeProcess?.pendingProxyCompletions;
5603
+ const entries = [...pending?.values() ?? []].filter(
5604
+ (entry) => force || entry.recoveryRequired || isPendingProxyCallChannelClosed(entry.call)
5605
+ );
5606
+ if (entries.length === 0) return false;
5607
+ endTextBlock();
5608
+ watchdogMessage = makeLateProxyResultMessage(entries);
5609
+ proc.stdin.write(watchdogMessage + "\n");
5610
+ for (const { call } of entries) pending.delete(call.toolCallId);
5611
+ log.warn("delivering proxy results after interrupted continuation", {
5612
+ sessionKey: sk,
5613
+ toolCallIds: entries.map(({ call }) => call.toolCallId),
5614
+ respawn: force
5615
+ });
5616
+ gotPartialEvents = false;
5617
+ hasReceivedContent = false;
5618
+ hasReceivedProgress = false;
5619
+ turnCompleted = false;
5620
+ resetAutoContinueWindow();
5621
+ clearFallbackTimer();
5622
+ armStartWatchdog();
5623
+ return true;
5624
+ };
5072
5625
  const toolCallMap = /* @__PURE__ */ new Map();
5073
5626
  const skipResultForIds = /* @__PURE__ */ new Set();
5074
5627
  const toolCallsById = /* @__PURE__ */ new Map();
@@ -5093,6 +5646,7 @@ ${plan}
5093
5646
  providerExecuted: false
5094
5647
  });
5095
5648
  skipResultForIds.add(call.toolCallId);
5649
+ markPendingProxyCallEmitted(call.toolCallId);
5096
5650
  }
5097
5651
  controller.enqueue({
5098
5652
  type: "finish",
@@ -5203,6 +5757,10 @@ ${plan}
5203
5757
  };
5204
5758
  const completeResult = (msg) => {
5205
5759
  if (controllerClosed) return;
5760
+ if (deliverPendingCompletions()) {
5761
+ if (drainBuffer.length > 0) drainNow();
5762
+ return;
5763
+ }
5206
5764
  if (drainBuffer.length > 0) {
5207
5765
  drainNow();
5208
5766
  return;
@@ -5214,6 +5772,7 @@ ${plan}
5214
5772
  count: pendingSiblings.length
5215
5773
  });
5216
5774
  }
5775
+ activeProcess?.pendingProxyCompletions?.clear();
5217
5776
  const autoDecision = shouldAutoContinueIncompleteTurn(
5218
5777
  autoContinueState,
5219
5778
  {
@@ -5300,10 +5859,15 @@ ${plan}
5300
5859
  if (!line.trim()) return;
5301
5860
  if (controllerClosed) return;
5302
5861
  startResultFallback();
5303
- clearStartWatchdog();
5304
5862
  try {
5305
5863
  const outer = JSON.parse(line);
5306
5864
  const msg = outer.type === "stream_event" && outer.event ? { ...outer.event, session_id: outer.session_id } : outer;
5865
+ const modelProgress = msg.type === "assistant" && !!msg.message?.content?.length || msg.type === "content_block_start" && msg.content_block?.type === "tool_use" || msg.type === "content_block_delta" && (msg.delta?.type === "text_delta" && !!msg.delta.text || msg.delta?.type === "thinking_delta" && !!msg.delta.thinking);
5866
+ if (modelProgress) {
5867
+ hasReceivedProgress = true;
5868
+ clearStartWatchdog();
5869
+ startResultFallback();
5870
+ }
5307
5871
  if (outer.type === "stream_event") {
5308
5872
  gotPartialEvents = true;
5309
5873
  }
@@ -5817,6 +6381,9 @@ ${plan}
5817
6381
  if (msg.session_id) {
5818
6382
  setClaudeSessionId(sk, msg.session_id);
5819
6383
  }
6384
+ if (deliverPendingCompletions()) {
6385
+ return;
6386
+ }
5820
6387
  if (!currentTextId && msg.is_error && typeof msg.result === "string" && msg.result.trim().length > 0) {
5821
6388
  const errId = startTextBlock();
5822
6389
  controller.enqueue({
@@ -5946,6 +6513,55 @@ ${plan}
5946
6513
  } catch {
5947
6514
  }
5948
6515
  };
6516
+ if (activeProcess) {
6517
+ const unattended = takeUnattendedLines(activeProcess);
6518
+ if (unattended.lines.length > 0 || unattended.dropped > 0) {
6519
+ log.notice("replaying stdout the child emitted between turns", {
6520
+ sessionKey: sk,
6521
+ lines: unattended.lines.length,
6522
+ dropped: unattended.dropped
6523
+ });
6524
+ let partialText = false;
6525
+ {
6526
+ if (unattended.dropped > 0) {
6527
+ const id = startTextBlock();
6528
+ controller.enqueue({
6529
+ type: "text-delta",
6530
+ id,
6531
+ delta: `> _${unattended.dropped} lines of output emitted between turns were dropped._
6532
+
6533
+ `
6534
+ });
6535
+ }
6536
+ for (const line of unattended.lines) {
6537
+ try {
6538
+ const outer = JSON.parse(line);
6539
+ const msg = outer.type === "stream_event" && outer.event ? outer.event : outer;
6540
+ let text = "";
6541
+ if (msg.type === "content_block_delta" && msg.delta?.type === "text_delta") {
6542
+ text = msg.delta.text ?? "";
6543
+ partialText = true;
6544
+ } else if (msg.type === "assistant") {
6545
+ if (!partialText) text = (msg.message?.content ?? []).filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
6546
+ partialText = false;
6547
+ } else if (msg.type === "result") {
6548
+ unattendedTurnEnded = true;
6549
+ for (const entry of activeProcess.pendingProxyCompletions?.values() ?? []) {
6550
+ if (isPendingProxyCallChannelClosed(entry.call)) entry.recoveryRequired = true;
6551
+ }
6552
+ if (outer.session_id) setClaudeSessionId(sk, outer.session_id);
6553
+ if (msg.is_error && msg.result) text = msg.result;
6554
+ }
6555
+ if (text) controller.enqueue({ type: "text-delta", id: startTextBlock(), delta: text });
6556
+ } catch {
6557
+ }
6558
+ }
6559
+ }
6560
+ endTextBlock();
6561
+ clearFallbackTimer();
6562
+ hasReceivedContent = false;
6563
+ }
6564
+ }
5949
6565
  lineEmitter.on("line", lineHandler);
5950
6566
  lineEmitter.on("close", closeHandler);
5951
6567
  pendingProxyUnsubscribe = onPendingProxyCall(sk, (call) => {
@@ -6015,11 +6631,21 @@ ${plan}
6015
6631
  if (hasMatchedPendingResults) {
6016
6632
  for (const { call, result } of previousPendingProxyMatches) {
6017
6633
  if (result) {
6634
+ const channelClosed = isPendingProxyCallChannelClosed(call);
6018
6635
  log.info("resolving pending proxy call from tool result prompt", {
6019
6636
  sessionKey: sk,
6020
6637
  toolCallId: call.toolCallId,
6021
- toolName: call.toolName
6638
+ toolName: call.toolName,
6639
+ channelClosed
6022
6640
  });
6641
+ const completions = activeProcess.pendingProxyCompletions ??= /* @__PURE__ */ new Map();
6642
+ if (!completions.has(call.toolCallId)) {
6643
+ completions.set(call.toolCallId, {
6644
+ call,
6645
+ result,
6646
+ recoveryRequired: channelClosed || unattendedTurnEnded
6647
+ });
6648
+ }
6023
6649
  resolvePendingProxyCallById(call.toolCallId, result);
6024
6650
  } else {
6025
6651
  log.info(
@@ -6032,6 +6658,22 @@ ${plan}
6032
6658
  );
6033
6659
  }
6034
6660
  }
6661
+ if (unattendedTurnEnded) deliverPendingCompletions();
6662
+ const unemitted = getPendingProxyCalls(sk).filter(
6663
+ (call) => !call.emitted
6664
+ );
6665
+ if (unemitted.length > 0) {
6666
+ log.notice("draining proxy calls queued between turns", {
6667
+ sessionKey: sk,
6668
+ toolCallIds: unemitted.map((call) => call.toolCallId)
6669
+ });
6670
+ drainBuffer.push(...unemitted);
6671
+ drainNow();
6672
+ return;
6673
+ }
6674
+ if (getPendingProxyCalls(sk).length === 0) {
6675
+ armStartWatchdog();
6676
+ }
6035
6677
  return;
6036
6678
  }
6037
6679
  if (previousPendingProxyCalls.length > 0) {
@@ -6075,7 +6717,7 @@ ${plan}
6075
6717
 
6076
6718
  // src/accounts.ts
6077
6719
  import { chmod, lstat, mkdir, readlink, symlink, writeFile } from "fs/promises";
6078
- import path5 from "path";
6720
+ import path6 from "path";
6079
6721
  var BASE_PROVIDER_ID = "claude-code";
6080
6722
  var DEFAULT_ACCOUNT = "default";
6081
6723
  var SHARED_CAPABILITY_ITEMS = [
@@ -6113,7 +6755,7 @@ function expandHome(value) {
6113
6755
  const home = process.env.HOME ?? process.env.USERPROFILE;
6114
6756
  if (value === "~") return home ?? value;
6115
6757
  if (value.startsWith("~/") || value.startsWith("~\\")) {
6116
- return home ? path5.join(home, value.slice(2)) : value;
6758
+ return home ? path6.join(home, value.slice(2)) : value;
6117
6759
  }
6118
6760
  return value;
6119
6761
  }
@@ -6145,8 +6787,8 @@ async function ensureSharedCapabilities(targetRoot) {
6145
6787
  }
6146
6788
  }
6147
6789
  async function ensureSharedCapabilityItem(sourceRoot, targetRoot, item) {
6148
- const source = path5.join(sourceRoot, item);
6149
- const target = path5.join(targetRoot, item);
6790
+ const source = path6.join(sourceRoot, item);
6791
+ const target = path6.join(targetRoot, item);
6150
6792
  let sourceStat;
6151
6793
  try {
6152
6794
  sourceStat = await lstat(source);
@@ -6157,8 +6799,8 @@ async function ensureSharedCapabilityItem(sourceRoot, targetRoot, item) {
6157
6799
  const targetStat = await lstat(target);
6158
6800
  if (targetStat.isSymbolicLink()) {
6159
6801
  const current = await readlink(target);
6160
- const resolvedCurrent = path5.resolve(path5.dirname(target), current);
6161
- const resolvedSource = path5.resolve(source);
6802
+ const resolvedCurrent = path6.resolve(path6.dirname(target), current);
6803
+ const resolvedSource = path6.resolve(source);
6162
6804
  if (resolvedCurrent === resolvedSource) return;
6163
6805
  }
6164
6806
  log.warn("shared Claude capability already exists; leaving untouched", {
@@ -6173,11 +6815,11 @@ async function ensureSharedCapabilityItem(sourceRoot, targetRoot, item) {
6173
6815
  await symlink(source, target, type);
6174
6816
  }
6175
6817
  async function writeAccountWrapper(account, baseCliPath, configDir) {
6176
- const cacheRoot = path5.join(
6818
+ const cacheRoot = path6.join(
6177
6819
  process.env.XDG_CACHE_HOME ?? expandHome("~/.cache"),
6178
6820
  "opencode-claude-code-plugin"
6179
6821
  );
6180
- const wrapperPath = path5.join(cacheRoot, `claude-${account}`);
6822
+ const wrapperPath = path6.join(cacheRoot, `claude-${account}`);
6181
6823
  const suffix = `@${account}`;
6182
6824
  await mkdir(cacheRoot, { recursive: true });
6183
6825
  const script = `#!/usr/bin/env bash
@@ -6329,15 +6971,15 @@ function cleanupOne(cacheRoot, ourDir) {
6329
6971
  // src/startup-diagnostics.ts
6330
6972
  import { execFile as execFile2 } from "child_process";
6331
6973
  import * as fs5 from "fs";
6332
- import * as path6 from "path";
6974
+ import * as path7 from "path";
6333
6975
  import { promisify as promisify2 } from "util";
6334
6976
  import { fileURLToPath as fileURLToPath2 } from "url";
6335
6977
  var cachedPluginVersion;
6336
6978
  function pluginVersion() {
6337
6979
  if (cachedPluginVersion) return cachedPluginVersion;
6338
6980
  try {
6339
- const here = path6.dirname(fileURLToPath2(import.meta.url));
6340
- const raw = fs5.readFileSync(path6.join(here, "..", "package.json"), "utf8");
6981
+ const here = path7.dirname(fileURLToPath2(import.meta.url));
6982
+ const raw = fs5.readFileSync(path7.join(here, "..", "package.json"), "utf8");
6341
6983
  const version = JSON.parse(raw).version;
6342
6984
  cachedPluginVersion = typeof version === "string" ? version : "unknown";
6343
6985
  } catch {
@@ -6361,7 +7003,7 @@ var opencodeVersionProbe;
6361
7003
  function detectOpencodeVersion(execPath = process.execPath) {
6362
7004
  if (opencodeVersionProbe) return opencodeVersionProbe;
6363
7005
  opencodeVersionProbe = (async () => {
6364
- if (!path6.basename(execPath).toLowerCase().includes("opencode")) {
7006
+ if (!path7.basename(execPath).toLowerCase().includes("opencode")) {
6365
7007
  log.debug("skipping opencode version probe: execPath is not opencode", { execPath });
6366
7008
  return void 0;
6367
7009
  }
@@ -6467,6 +7109,13 @@ var DEFAULT_PROXY_TOOL_NAMES = [
6467
7109
  "WebFetch",
6468
7110
  "Task"
6469
7111
  ];
7112
+ function registerSideQuestionCommand(config) {
7113
+ config.command ??= {};
7114
+ config.command.btw ??= {
7115
+ template: "/btw $ARGUMENTS",
7116
+ description: "Ask a side question in the live Claude Code session without changing its context"
7117
+ };
7118
+ }
6470
7119
  function warnIfAnthropicApiKey(ignore) {
6471
7120
  if (warnedAnthropicApiKey) return;
6472
7121
  if (!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_AUTH_TOKEN) return;
@@ -6542,6 +7191,7 @@ function pluginEntrypoint() {
6542
7191
  function cleanProviderOptions(options = {}) {
6543
7192
  const result = { ...options };
6544
7193
  delete result.accounts;
7194
+ delete result.defaultSubagentModel;
6545
7195
  return result;
6546
7196
  }
6547
7197
  function defaultModelsForProvider(providerModels, providerID = PROVIDER_ID2, modelSuffix) {
@@ -6678,6 +7328,37 @@ async function expandAccountProviders(config) {
6678
7328
  }
6679
7329
  return expandedCount > 0;
6680
7330
  }
7331
+ async function buildAgentRegistry(config) {
7332
+ const options = config.provider?.[PROVIDER_ID2]?.options;
7333
+ const configured = options?.defaultSubagentModel;
7334
+ setDefaultSubagentModel(
7335
+ typeof configured === "string" ? configured : void 0
7336
+ );
7337
+ const records = await readAgentMarkdownRecords(
7338
+ agentDirectories(
7339
+ process.env.HOME ?? process.env.USERPROFILE,
7340
+ getOpencodeProjectDirectory()
7341
+ )
7342
+ );
7343
+ for (const [name, agent] of Object.entries(config.agent ?? {})) {
7344
+ const bag = agent.options ?? {};
7345
+ const pick = (key) => {
7346
+ const value = agent[key] ?? bag[key];
7347
+ return typeof value === "string" ? value : void 0;
7348
+ };
7349
+ records[name] = {
7350
+ mode: pick("mode") ?? records[name]?.mode,
7351
+ model: pick("model") ?? records[name]?.model,
7352
+ forceModel: pick("forceModel") ?? records[name]?.forceModel,
7353
+ reasoningEffort: pick("reasoningEffort") ?? records[name]?.reasoningEffort
7354
+ };
7355
+ }
7356
+ setAgentRegistry(records);
7357
+ log.debug("agent registry built", {
7358
+ agents: Object.keys(records).length,
7359
+ defaultSubagentModel: getDefaultSubagentModel()
7360
+ });
7361
+ }
6681
7362
  var server = async (input) => {
6682
7363
  cleanupStaleUnscopedInstall();
6683
7364
  const opencodeVersion = pickOpencodeVersion(input);
@@ -6687,7 +7368,9 @@ var server = async (input) => {
6687
7368
  setOpencodeProjectDirectory(pickOpencodeDirectory(input));
6688
7369
  return {
6689
7370
  config: async (config) => {
7371
+ registerSideQuestionCommand(config);
6690
7372
  config.provider ??= {};
7373
+ await buildAgentRegistry(config);
6691
7374
  const expanded = await expandAccountProviders(config);
6692
7375
  if (expanded) {
6693
7376
  logStartupDiagnostics(
@@ -6758,6 +7441,10 @@ export {
6758
7441
  configModelsForProvider,
6759
7442
  createClaudeCode,
6760
7443
  index_default as default,
6761
- defaultModels
7444
+ defaultModels,
7445
+ getAgentRegistry,
7446
+ getDefaultSubagentModel,
7447
+ registerSideQuestionCommand,
7448
+ resolveAgentModel
6762
7449
  };
6763
7450
  //# sourceMappingURL=index.js.map