@khalilgharbaoui/opencode-claude-code-plugin 0.33.2 → 0.34.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
@@ -1306,6 +1306,34 @@ import { execFile } from "child_process";
1306
1306
  import { promisify } from "util";
1307
1307
  var execFileAsync = promisify(execFile);
1308
1308
  var cache = /* @__PURE__ */ new Map();
1309
+ var PROBE_TIMEOUT_MS = 5e3;
1310
+ var probeTimeoutMs = PROBE_TIMEOUT_MS;
1311
+ var MAX_DEADLINE_REPROBES = 2;
1312
+ var deadlineReprobes = /* @__PURE__ */ new Map();
1313
+ function killedByDeadline(error) {
1314
+ const failure = error;
1315
+ return failure?.killed === true && typeof failure.signal === "string" && typeof failure.code !== "string";
1316
+ }
1317
+ function forgetDeadlineKill(entries, key, probe, killed) {
1318
+ void probe.then(() => {
1319
+ if (entries.get(key) !== probe) return;
1320
+ if (!killed()) {
1321
+ deadlineReprobes.delete(key);
1322
+ return;
1323
+ }
1324
+ const reprobes = deadlineReprobes.get(key) ?? 0;
1325
+ if (reprobes >= MAX_DEADLINE_REPROBES) {
1326
+ log.warn("claude kept missing the probe deadline; keeping the conservative answer for this process", {
1327
+ key: key.replace("\0", " "),
1328
+ deadlineMs: probeTimeoutMs,
1329
+ attempts: reprobes + 1
1330
+ });
1331
+ return;
1332
+ }
1333
+ deadlineReprobes.set(key, reprobes + 1);
1334
+ entries.delete(key);
1335
+ });
1336
+ }
1309
1337
  var CLI_HYGIENE_ENV_VARS = [
1310
1338
  "DISABLE_AUTOUPDATER",
1311
1339
  "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
@@ -1320,10 +1348,11 @@ function cliHygieneEnv(inherited = process.env) {
1320
1348
  function detectCliVersion(cliPath) {
1321
1349
  const cached = cache.get(cliPath);
1322
1350
  if (cached) return cached;
1351
+ let deadlineKill = false;
1323
1352
  const promise = (async () => {
1324
1353
  try {
1325
1354
  const { stdout } = await execFileAsync(cliPath, ["--version"], {
1326
- timeout: 5e3
1355
+ timeout: probeTimeoutMs
1327
1356
  });
1328
1357
  const match = /(\d+)\.(\d+)\.(\d+)/.exec(stdout.trim());
1329
1358
  if (!match) {
@@ -1345,14 +1374,17 @@ function detectCliVersion(cliPath) {
1345
1374
  }
1346
1375
  return v;
1347
1376
  } catch (err) {
1377
+ deadlineKill = killedByDeadline(err);
1348
1378
  log.warn("failed to detect claude cli version", {
1349
1379
  cliPath,
1350
- error: err instanceof Error ? err.message : String(err)
1380
+ error: err instanceof Error ? err.message : String(err),
1381
+ deadlineKill
1351
1382
  });
1352
1383
  return null;
1353
1384
  }
1354
1385
  })();
1355
1386
  cache.set(cliPath, promise);
1387
+ forgetDeadlineKill(cache, cliPath, promise, () => deadlineKill);
1356
1388
  return promise;
1357
1389
  }
1358
1390
  function gte(v, target) {
@@ -1389,10 +1421,11 @@ function detectCliSupportsFlag(cliPath, flag) {
1389
1421
  const key = `${cliPath}\0${flag}`;
1390
1422
  const cached = flagSupport.get(key);
1391
1423
  if (cached) return cached;
1424
+ let deadlineKill = false;
1392
1425
  const promise = (async () => {
1393
1426
  try {
1394
1427
  const execution = execFileAsync(cliPath, ["--help"], {
1395
- timeout: 5e3,
1428
+ timeout: probeTimeoutMs,
1396
1429
  killSignal: "SIGKILL",
1397
1430
  maxBuffer: 4 * 1024 * 1024
1398
1431
  });
@@ -1400,15 +1433,18 @@ function detectCliSupportsFlag(cliPath, flag) {
1400
1433
  const { stdout } = await execution;
1401
1434
  return stdout.includes(flag);
1402
1435
  } catch (err) {
1436
+ deadlineKill = killedByDeadline(err);
1403
1437
  log.warn("failed to probe claude cli flag support", {
1404
1438
  cliPath,
1405
1439
  flag,
1406
- error: err instanceof Error ? err.message : String(err)
1440
+ error: err instanceof Error ? err.message : String(err),
1441
+ deadlineKill
1407
1442
  });
1408
1443
  return false;
1409
1444
  }
1410
1445
  })();
1411
1446
  flagSupport.set(key, promise);
1447
+ forgetDeadlineKill(flagSupport, key, promise, () => deadlineKill);
1412
1448
  return promise;
1413
1449
  }
1414
1450
 
@@ -2945,2063 +2981,2106 @@ function clearCompression(sessionKey2) {
2945
2981
  compressions.delete(sessionKey2);
2946
2982
  }
2947
2983
 
2948
- // src/runtime-status.ts
2949
- var opencodeClient = null;
2950
- function setOpencodeClient(client) {
2951
- if (client && typeof client === "object") {
2952
- opencodeClient = client;
2953
- }
2954
- }
2955
- function getOpencodeClient() {
2956
- return opencodeClient;
2957
- }
2958
- var opencodeProjectDirectory;
2959
- function setOpencodeProjectDirectory(dir) {
2960
- opencodeProjectDirectory = dir;
2961
- }
2962
- function getOpencodeProjectDirectory() {
2963
- return opencodeProjectDirectory;
2964
- }
2965
- function isUsableDirectory(d) {
2966
- return typeof d === "string" && d.length > 1 && d !== "/";
2967
- }
2968
- function resolveSpawnCwdFrom(configured, live, captured, sessionDir) {
2969
- if (configured) return configured;
2970
- if (isUsableDirectory(sessionDir)) return sessionDir;
2971
- if (isUsableDirectory(live)) return live;
2972
- return captured ?? live;
2973
- }
2974
- async function resolveSpawnCwdForSession(configured, sessionID) {
2975
- if (configured) return configured;
2976
- const sessionDir = sessionID ? await fetchSessionDirectory(sessionID) : void 0;
2977
- return resolveSpawnCwdFrom(
2978
- configured,
2979
- process.cwd(),
2980
- opencodeProjectDirectory,
2981
- sessionDir
2982
- );
2983
- }
2984
- async function fetchSessionDirectory(sessionID) {
2985
- if (!sessionID || sessionID === "default") return void 0;
2986
- const client = opencodeClient;
2987
- if (!client?.session?.get) return void 0;
2984
+ // src/mcp-bridge.ts
2985
+ import * as fs3 from "fs";
2986
+ import * as path4 from "path";
2987
+ import * as os2 from "os";
2988
+ import * as crypto2 from "crypto";
2989
+ import {
2990
+ parse as parseJsonc,
2991
+ printParseErrorCode
2992
+ } from "jsonc-parser";
2993
+ var FILE_NAMES = ["opencode.jsonc", "opencode.json", "config.json"];
2994
+ var PROJECT_FILE_NAMES = ["opencode.json", "opencode.jsonc"];
2995
+ function fileExists(p) {
2988
2996
  try {
2989
- const res = await client.session.get({ path: { id: sessionID } });
2990
- const data = res.data;
2991
- if (!data || typeof data !== "object") return void 0;
2992
- const dir = data.directory;
2993
- return isUsableDirectory(dir) ? dir : void 0;
2994
- } catch (err) {
2995
- log.warn("failed to fetch opencode session directory", {
2996
- sessionID,
2997
- error: err instanceof Error ? err.message : String(err)
2998
- });
2999
- return void 0;
2997
+ return fs3.statSync(p).isFile();
2998
+ } catch {
2999
+ return false;
3000
3000
  }
3001
3001
  }
3002
- async function fetchSessionParentId(sessionID) {
3003
- if (!sessionID || sessionID === "default") return void 0;
3004
- const client = opencodeClient;
3005
- if (!client?.session?.get) return void 0;
3002
+ function dirExists(p) {
3006
3003
  try {
3007
- const res = await client.session.get({ path: { id: sessionID } });
3008
- const data = res.data;
3009
- if (!data || typeof data !== "object") return void 0;
3010
- const parentID = data.parentID;
3011
- return typeof parentID === "string" && parentID.length > 0 ? parentID : void 0;
3012
- } catch (err) {
3013
- log.warn("failed to fetch opencode session parent", {
3014
- sessionID,
3015
- error: err instanceof Error ? err.message : String(err)
3016
- });
3017
- return void 0;
3004
+ return fs3.statSync(p).isDirectory();
3005
+ } catch {
3006
+ return false;
3018
3007
  }
3019
3008
  }
3020
- async function fetchSessionRunState(sessionID) {
3021
- if (!sessionID || sessionID === "default") return "unknown";
3022
- const status = opencodeClient?.session?.status;
3023
- if (!status) return "unknown";
3009
+ function readAndParse(file) {
3024
3010
  try {
3025
- const res = await status.call(opencodeClient.session);
3026
- const data = res.data;
3027
- if (!data || typeof data !== "object") return "unknown";
3028
- const entry = data[sessionID];
3029
- if (!entry || typeof entry !== "object") return "idle";
3030
- const type = entry.type;
3031
- return type === "idle" ? "idle" : "busy";
3032
- } catch (err) {
3033
- log.debug("failed to read opencode session status", {
3034
- sessionID,
3035
- error: err instanceof Error ? err.message : String(err)
3011
+ const raw = fs3.readFileSync(file, "utf8");
3012
+ const errors = [];
3013
+ const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
3014
+ if (errors.length > 0) {
3015
+ const first = errors[0];
3016
+ throw new Error(
3017
+ `${printParseErrorCode(first.error)} at offset ${first.offset}`
3018
+ );
3019
+ }
3020
+ return parsed;
3021
+ } catch (e) {
3022
+ log.warn("failed to parse opencode config", {
3023
+ file,
3024
+ error: e instanceof Error ? e.message : String(e)
3036
3025
  });
3037
- return "unknown";
3026
+ return null;
3038
3027
  }
3039
3028
  }
3040
- async function settleSessionRunState(sessionID, options = {}) {
3041
- const pollMs = options.pollMs ?? 150;
3042
- const windowMs = options.windowMs ?? 1500;
3043
- const started = Date.now();
3044
- let last = "unknown";
3045
- for (; ; ) {
3046
- const state = await fetchSessionRunState(sessionID);
3047
- if (state === "busy") return "busy";
3048
- if (state !== "unknown") last = state;
3049
- if (Date.now() - started >= windowMs) return last;
3050
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
3051
- }
3029
+ function isPlainObject(x) {
3030
+ return typeof x === "object" && x !== null && !Array.isArray(x);
3052
3031
  }
3053
- async function getRuntimeMcpStatus() {
3054
- const client = opencodeClient;
3055
- if (!client?.mcp?.status) return void 0;
3056
- try {
3057
- const res = await client.mcp.status();
3058
- const data = res.data;
3059
- if (!data || typeof data !== "object") return void 0;
3060
- const out = {};
3061
- for (const [name, entry] of Object.entries(data)) {
3062
- if (entry && typeof entry === "object") {
3063
- const status = entry.status;
3064
- if (typeof status === "string") out[name] = status;
3065
- }
3032
+ function deepMerge(target, source) {
3033
+ const out = { ...target };
3034
+ for (const [k, v] of Object.entries(source)) {
3035
+ if (v === void 0) continue;
3036
+ const existing = out[k];
3037
+ if (isPlainObject(existing) && isPlainObject(v)) {
3038
+ out[k] = deepMerge(existing, v);
3039
+ } else {
3040
+ out[k] = v;
3066
3041
  }
3067
- return out;
3068
- } catch (err) {
3069
- log.warn("failed to fetch opencode MCP runtime status", {
3070
- error: err instanceof Error ? err.message : String(err)
3071
- });
3072
- return void 0;
3073
3042
  }
3043
+ return out;
3074
3044
  }
3075
- async function fetchOpencodeToolList(provider, model, directory) {
3076
- const client = opencodeClient;
3077
- if (!client?.tool?.list) return void 0;
3078
- try {
3079
- const res = await client.tool.list({
3080
- query: { provider, model, ...directory ? { directory } : {} }
3081
- });
3082
- const data = res.data;
3083
- if (!Array.isArray(data)) return void 0;
3084
- const out = [];
3085
- for (const entry of data) {
3086
- if (!entry || typeof entry !== "object") continue;
3087
- const e = entry;
3088
- const id = typeof e.id === "string" ? e.id : null;
3089
- const description = typeof e.description === "string" ? e.description : "";
3090
- const parameters = e.parameters && typeof e.parameters === "object" ? e.parameters : {};
3091
- if (!id) continue;
3092
- out.push({ id, description, parameters });
3045
+ function walkUp(opts) {
3046
+ const out = [];
3047
+ let current = path4.resolve(opts.start);
3048
+ while (true) {
3049
+ for (const target of opts.targets) {
3050
+ const candidate = path4.join(current, target);
3051
+ if (opts.predicate(candidate)) out.push(candidate);
3093
3052
  }
3094
- return out;
3095
- } catch (err) {
3096
- log.warn("failed to fetch opencode tool list", {
3097
- provider,
3098
- model,
3099
- error: err instanceof Error ? err.message : String(err)
3100
- });
3101
- return void 0;
3053
+ if (opts.stop && current === path4.resolve(opts.stop)) break;
3054
+ const parent = path4.dirname(current);
3055
+ if (parent === current) break;
3056
+ current = parent;
3102
3057
  }
3058
+ return out;
3103
3059
  }
3104
- async function fetchSessionReplies(sessionID) {
3105
- if (!sessionID || sessionID === "default") return void 0;
3106
- const client = opencodeClient;
3107
- if (!client?.session?.messages) return void 0;
3108
- try {
3109
- const res = await client.session.messages({ path: { id: sessionID } });
3110
- const data = res.data;
3111
- if (!Array.isArray(data)) return void 0;
3112
- const out = [];
3113
- for (const entry of data) {
3114
- if (!entry || typeof entry !== "object") continue;
3115
- const info = entry.info;
3116
- if (!info || typeof info !== "object") continue;
3117
- const record = info;
3118
- const parts = entry.parts;
3119
- const text = Array.isArray(parts) ? parts.flatMap((part) => {
3120
- if (!part || typeof part !== "object") return [];
3121
- const p = part;
3122
- if (p.type !== "text" || typeof p.text !== "string") return [];
3123
- return p.synthetic === true ? [] : [p.text];
3124
- }).join("\n").trim() : "";
3125
- const error = record.error;
3126
- const message = error && typeof error === "object" ? typeof error.data?.message === "string" ? error.data.message : typeof error.name === "string" ? error.name : void 0 : void 0;
3127
- out.push({
3128
- role: typeof record.role === "string" ? record.role : "",
3129
- completed: typeof record.time?.completed === "number",
3130
- error: message,
3131
- text
3132
- });
3060
+ function detectWorktree(cwd) {
3061
+ const override = process.env.OPENCODE_WORKTREE;
3062
+ if (override) return path4.resolve(override);
3063
+ let current = path4.resolve(cwd);
3064
+ while (true) {
3065
+ const gitPath = path4.join(current, ".git");
3066
+ try {
3067
+ if (fs3.existsSync(gitPath)) return current;
3068
+ } catch {
3133
3069
  }
3134
- return out;
3135
- } catch (err) {
3136
- log.warn("failed to fetch opencode session messages", {
3137
- sessionID,
3138
- error: err instanceof Error ? err.message : String(err)
3139
- });
3140
- return void 0;
3070
+ const parent = path4.dirname(current);
3071
+ if (parent === current) return void 0;
3072
+ current = parent;
3141
3073
  }
3142
3074
  }
3143
- async function abortSession(sessionID) {
3144
- if (!sessionID || sessionID === "default") return false;
3145
- const client = opencodeClient;
3146
- if (!client?.session?.abort) return false;
3147
- try {
3148
- const res = await client.session.abort({ path: { id: sessionID } });
3149
- return res.data !== false;
3150
- } catch (err) {
3151
- log.warn("failed to abort opencode session", {
3152
- sessionID,
3153
- error: err instanceof Error ? err.message : String(err)
3154
- });
3155
- return false;
3156
- }
3075
+ function globalConfigDir() {
3076
+ const xdg = process.env.XDG_CONFIG_HOME ?? path4.join(os2.homedir(), ".config");
3077
+ return path4.join(xdg, "opencode");
3157
3078
  }
3158
-
3159
- // src/background-tasks.ts
3160
- var TASK_STATUS_TOOL_NAME = "task_status";
3161
- var TASK_CANCEL_TOOL_NAME = "task_cancel";
3162
- var MAX_LEDGERS = 32;
3163
- var MAX_COLLECTED_PER_SESSION = 64;
3164
- var collected = /* @__PURE__ */ new Map();
3165
- var cancelled = /* @__PURE__ */ new Map();
3166
- function ledgerIn(ledgers2, sessionKey2) {
3167
- let ids = ledgers2.get(sessionKey2);
3168
- if (!ids) {
3169
- ids = /* @__PURE__ */ new Set();
3170
- ledgers2.set(sessionKey2, ids);
3171
- while (ledgers2.size > MAX_LEDGERS) {
3172
- const oldest = ledgers2.keys().next();
3173
- if (oldest.done) break;
3174
- ledgers2.delete(oldest.value);
3175
- }
3079
+ function loadGlobalConfig(hostApi = "v1") {
3080
+ const dir = globalConfigDir();
3081
+ let merged = {};
3082
+ for (const name of FILE_NAMES.slice().reverse()) {
3083
+ const file = path4.join(dir, name);
3084
+ if (!fileExists(file)) continue;
3085
+ const parsed = readAndParse(file);
3086
+ if (parsed) merged = mergeConfigLayer(merged, parsed, hostApi);
3176
3087
  }
3177
- return ids;
3088
+ return merged;
3178
3089
  }
3179
- function mark(ledgers2, sessionKey2, taskId) {
3180
- const ids = ledgerIn(ledgers2, sessionKey2);
3181
- ids.add(taskId);
3182
- while (ids.size > MAX_COLLECTED_PER_SESSION) {
3183
- const oldest = ids.values().next();
3184
- if (oldest.done) break;
3185
- ids.delete(oldest.value);
3090
+ function loadProjectFilesInDir(dir, hostApi = "v1") {
3091
+ let merged = {};
3092
+ for (const name of PROJECT_FILE_NAMES) {
3093
+ const file = path4.join(dir, name);
3094
+ if (!fileExists(file)) continue;
3095
+ const parsed = readAndParse(file);
3096
+ if (parsed) merged = mergeConfigLayer(merged, parsed, hostApi);
3186
3097
  }
3098
+ return merged;
3187
3099
  }
3188
- function markCollected(sessionKey2, taskId) {
3189
- mark(collected, sessionKey2, taskId);
3190
- }
3191
- function hasCollectedBackgroundTask(sessionKey2, taskId) {
3192
- return collected.get(sessionKey2)?.has(taskId) === true;
3193
- }
3194
- function clearBackgroundTasks(sessionKey2) {
3195
- collected.delete(sessionKey2);
3196
- cancelled.delete(sessionKey2);
3197
- }
3198
- var lastGate;
3199
- function recordBackgroundSubagentGate(gate, now = Date.now()) {
3200
- lastGate = { ...gate, at: now };
3201
- }
3202
- function snapshotBackgroundSubagentGate() {
3203
- return lastGate ? { ...lastGate } : void 0;
3204
- }
3205
- function snapshotBackgroundTasks() {
3206
- const keys = /* @__PURE__ */ new Set([...collected.keys(), ...cancelled.keys()]);
3207
- return [...keys].map((sessionKey2) => ({
3208
- sessionKey: sessionKey2,
3209
- collected: [...collected.get(sessionKey2) ?? []],
3210
- cancelled: [...cancelled.get(sessionKey2) ?? []]
3211
- }));
3212
- }
3213
- function readTaskId(input) {
3214
- const raw = input.task_id ?? input.taskId ?? input.sessionID;
3215
- if (typeof raw !== "string") return null;
3216
- const id = raw.trim();
3217
- return id.length > 0 ? id : null;
3218
- }
3219
- var MISSING_TASK_ID = 'task_id is required: pass the background subagent\'s own opencode session id, from the `<task id="...">` envelope on opencode 1.x or the `sessionID` the background dispatch reported on opencode 2.';
3220
- async function guardParent(taskId, callerSessionId) {
3221
- if (!callerSessionId || callerSessionId === "default") {
3222
- return `Cannot check that task_id ${taskId} belongs to this conversation (this turn has no opencode session id), so it is not inspected or cancelled.`;
3100
+ function dotOpencodeDirs(cwd, worktree) {
3101
+ const dirs = [];
3102
+ const seen = /* @__PURE__ */ new Set();
3103
+ const push = (p) => {
3104
+ const abs = path4.resolve(p);
3105
+ if (!seen.has(abs) && dirExists(abs)) {
3106
+ seen.add(abs);
3107
+ dirs.push(abs);
3108
+ }
3109
+ };
3110
+ for (const dir of walkUp({
3111
+ start: cwd,
3112
+ stop: worktree,
3113
+ targets: [".opencode"],
3114
+ predicate: dirExists
3115
+ })) {
3116
+ push(dir);
3223
3117
  }
3224
- const parent = await fetchSessionParentId(taskId);
3225
- if (parent === callerSessionId) return null;
3226
- return `task_id ${taskId} is not a subagent of this conversation, so it cannot be inspected or cancelled from here. Use the session id from a background dispatch this conversation itself made.`;
3227
- }
3228
- function isBackgroundTaskRunning(runState, replies) {
3229
- if (runState === "busy") return true;
3230
- if (runState === "idle") return false;
3231
- const reply = lastAssistantReply(replies);
3232
- return reply !== void 0 && !reply.completed && reply.error === void 0;
3118
+ const home = os2.homedir();
3119
+ if (home) {
3120
+ const homeDot = path4.join(home, ".opencode");
3121
+ if (dirExists(homeDot)) push(homeDot);
3122
+ }
3123
+ const envDir = process.env.OPENCODE_CONFIG_DIR;
3124
+ if (envDir && dirExists(envDir)) push(envDir);
3125
+ return dirs;
3233
3126
  }
3234
- function lastAssistantReply(replies) {
3235
- for (let index = replies.length - 1; index >= 0; index--) {
3236
- const reply = replies[index];
3237
- if (reply.role === "assistant") return reply;
3127
+ function substituteEnvPlaceholders(source) {
3128
+ const out = {};
3129
+ for (const [k, v] of Object.entries(source)) {
3130
+ if (typeof v !== "string") continue;
3131
+ out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
3132
+ const resolved = process.env[name];
3133
+ return typeof resolved === "string" ? resolved : "";
3134
+ });
3238
3135
  }
3239
- return void 0;
3136
+ return out;
3240
3137
  }
3241
- async function collectBackgroundTask(input, options) {
3242
- const taskId = readTaskId(input);
3243
- if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
3244
- const refused = await guardParent(taskId, options.callerSessionId);
3245
- if (refused) return { kind: "error", message: refused };
3246
- const replies = await fetchSessionReplies(taskId);
3247
- if (replies === void 0) {
3248
- return {
3249
- kind: "error",
3250
- message: `Could not read background task ${taskId} from opencode. It may have been deleted, or this opencode build does not expose the session transcript. The completion notification is still the primary delivery path: end your turn and wait for it.`
3138
+ function translateServer(name, spec) {
3139
+ if (spec.enabled === false) return null;
3140
+ const type = spec.type;
3141
+ if (type === "local") {
3142
+ const cmd = spec.command;
3143
+ if (!Array.isArray(cmd) || cmd.length === 0) {
3144
+ log.warn("skipping local MCP server with no command", { name });
3145
+ return null;
3146
+ }
3147
+ const out = {
3148
+ type: "stdio",
3149
+ command: String(cmd[0])
3251
3150
  };
3151
+ if (cmd.length > 1) out.args = cmd.slice(1).map((s) => String(s));
3152
+ if (spec.environment && typeof spec.environment === "object") {
3153
+ out.env = substituteEnvPlaceholders(
3154
+ spec.environment
3155
+ );
3156
+ }
3157
+ return out;
3252
3158
  }
3253
- const runState = await fetchSessionRunState(taskId);
3254
- if (isBackgroundTaskRunning(runState, replies)) {
3255
- return {
3256
- kind: "text",
3257
- text: `<task id="${taskId}" state="running">
3258
- Still working. Do not call this again in a loop: when it finishes opencode delivers the result to this conversation on its own. End your turn, or work on something that does not overlap it.
3259
- </task>`
3159
+ if (type === "remote") {
3160
+ if (typeof spec.url !== "string" || !spec.url) {
3161
+ log.warn("skipping remote MCP server with no url", { name });
3162
+ return null;
3163
+ }
3164
+ const out = {
3165
+ type: "http",
3166
+ url: spec.url
3260
3167
  };
3168
+ if (spec.headers && typeof spec.headers === "object") {
3169
+ out.headers = substituteEnvPlaceholders(
3170
+ spec.headers
3171
+ );
3172
+ }
3173
+ return out;
3261
3174
  }
3262
- const reply = lastAssistantReply(replies);
3263
- if (!reply) {
3264
- return {
3265
- kind: "text",
3266
- text: `<task id="${taskId}" state="unknown">
3267
- opencode has no assistant reply for this task yet and it is not running. It may still be starting up.
3268
- </task>`
3175
+ log.warn("skipping MCP server with unknown type", {
3176
+ name,
3177
+ type: type ?? null
3178
+ });
3179
+ return null;
3180
+ }
3181
+ function extractMcpBlock(config) {
3182
+ const mcp = config.mcp;
3183
+ if (!isPlainObject(mcp)) return {};
3184
+ if ("servers" in mcp && !isPlainObject(mcp.servers)) return {};
3185
+ const native = isPlainObject(mcp.servers) && typeof mcp.servers.type !== "string" && typeof mcp.servers.enabled !== "boolean" && typeof mcp.servers.disabled !== "boolean";
3186
+ const block = native ? mcp.servers : mcp;
3187
+ return Object.fromEntries(
3188
+ Object.entries(block).filter(([name, spec]) => isPlainObject(spec) && (native || name !== "timeout" || typeof spec.type === "string")).map(([name, spec]) => {
3189
+ const server2 = spec;
3190
+ return [name, {
3191
+ ...server2,
3192
+ ...typeof server2.disabled === "boolean" ? { enabled: !server2.disabled } : {}
3193
+ }];
3194
+ })
3195
+ );
3196
+ }
3197
+ function mergeConfigLayer(target, source, hostApi) {
3198
+ const merged = deepMerge(target, source);
3199
+ if (hostApi === "v2" && isPlainObject(source.mcp)) {
3200
+ merged.mcp = {
3201
+ ...isPlainObject(merged.mcp) ? merged.mcp : {},
3202
+ servers: { ...extractMcpBlock(target), ...extractMcpBlock(source) }
3269
3203
  };
3270
3204
  }
3271
- if (reply.error) {
3272
- markCollected(options.sessionKey, taskId);
3273
- return {
3274
- kind: "text",
3275
- isError: true,
3276
- text: `<task id="${taskId}" state="error">
3277
- ${reply.error}
3278
- </task>`
3279
- };
3205
+ return merged;
3206
+ }
3207
+ function mergeMcp(target, source) {
3208
+ const out = { ...target };
3209
+ for (const [name, spec] of Object.entries(source)) {
3210
+ if (!spec || typeof spec !== "object") continue;
3211
+ const existing = out[name];
3212
+ if (existing && typeof existing === "object") {
3213
+ out[name] = deepMerge(
3214
+ existing,
3215
+ spec
3216
+ );
3217
+ } else {
3218
+ out[name] = spec;
3219
+ }
3280
3220
  }
3281
- if (hasCollectedBackgroundTask(options.sessionKey, taskId)) {
3282
- return {
3283
- kind: "text",
3284
- text: `<task id="${taskId}" state="completed">
3285
- Already delivered to this conversation earlier (by this tool or by opencode's completion notification). Scroll back for the result rather than collecting it again.
3286
- </task>`
3287
- };
3221
+ return out;
3222
+ }
3223
+ var MCP_PENDING_STATUS = "pending";
3224
+ function bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers, hostApi = "v1") {
3225
+ const {
3226
+ servers: merged,
3227
+ enabledServerNames: allEnabledServerNames,
3228
+ hash
3229
+ } = mergeOpencodeMcp(cwd, runtimeStatus, hostApi);
3230
+ const servers = {};
3231
+ const bridgedServerNames = [];
3232
+ for (const [name, spec] of Object.entries(merged)) {
3233
+ if (!spec || typeof spec !== "object") continue;
3234
+ if (excludeServers?.has(name)) continue;
3235
+ const translated = translateServer(name, spec);
3236
+ if (translated) {
3237
+ servers[name] = translated;
3238
+ bridgedServerNames.push(name);
3239
+ }
3288
3240
  }
3289
- markCollected(options.sessionKey, taskId);
3290
- log.info("collected background subagent result", {
3291
- sessionKey: options.sessionKey,
3292
- taskId,
3293
- textLength: reply.text.length
3241
+ return finishBridge({
3242
+ servers,
3243
+ bridgedServerNames,
3244
+ allEnabledServerNames,
3245
+ hash,
3246
+ excludeServers
3294
3247
  });
3295
- return {
3296
- kind: "text",
3297
- text: `<task id="${taskId}" state="completed">
3298
- <task_result>
3299
- ${reply.text || "(the subagent produced no text)"}
3300
- </task_result>
3301
- </task>`
3302
- };
3303
3248
  }
3304
- async function cancelBackgroundTask(input, options) {
3305
- const taskId = readTaskId(input);
3306
- if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
3307
- const refused = await guardParent(taskId, options.callerSessionId);
3308
- if (refused) return { kind: "error", message: refused };
3309
- const running = isBackgroundTaskRunning(
3310
- await fetchSessionRunState(taskId),
3311
- await fetchSessionReplies(taskId) ?? []
3312
- );
3313
- const aborted = await abortSession(taskId);
3314
- if (!aborted) {
3315
- return {
3316
- kind: "error",
3317
- message: `Could not cancel background task ${taskId}: opencode did not accept the abort. It may have already finished, or this opencode build does not expose session abort.`
3318
- };
3249
+ function opencodeConfigLayers(cwd, hostApi = "v1") {
3250
+ const worktree = hostApi === "v2" ? void 0 : detectWorktree(cwd);
3251
+ const layers = [];
3252
+ layers.push(loadGlobalConfig(hostApi));
3253
+ const explicitConfig = process.env.OPENCODE_CONFIG;
3254
+ if (explicitConfig && fileExists(explicitConfig)) {
3255
+ const parsed = readAndParse(explicitConfig);
3256
+ if (parsed) layers.push(parsed);
3319
3257
  }
3320
- collected.get(options.sessionKey)?.delete(taskId);
3321
- mark(cancelled, options.sessionKey, taskId);
3322
- log.info("cancelled background subagent", {
3323
- sessionKey: options.sessionKey,
3324
- taskId,
3325
- wasRunning: running
3258
+ const projectFiles = walkUp({
3259
+ start: cwd,
3260
+ stop: worktree,
3261
+ targets: PROJECT_FILE_NAMES,
3262
+ predicate: fileExists
3326
3263
  });
3327
- return {
3328
- kind: "text",
3329
- text: `<task id="${taskId}" state="cancelled">
3330
- ` + (running ? "Stopped. No completion notification will arrive for it." : "It was not running when the cancel was sent, so nothing was interrupted. No completion notification will arrive for it.") + "\n</task>"
3331
- };
3332
- }
3333
-
3334
- // src/permission-presets.ts
3335
- var READ_ONLY_DISALLOWED_CLI_TOOLS = [
3336
- "Bash",
3337
- "Write",
3338
- "Edit",
3339
- "NotebookEdit",
3340
- "REPL",
3341
- "JavaScript",
3342
- "WebFetch"
3343
- ];
3344
- var READ_ONLY_DENIED_PROXY_TOOLS = /* @__PURE__ */ new Set([
3345
- "bash",
3346
- "write",
3347
- "edit",
3348
- "webfetch",
3349
- "task",
3350
- "task_batch"
3351
- ]);
3352
- function dedupe(names) {
3353
- const out = [];
3354
- const seen = /* @__PURE__ */ new Set();
3355
- for (const name of names) {
3356
- const trimmed = String(name).trim();
3357
- if (!trimmed || seen.has(trimmed)) continue;
3358
- seen.add(trimmed);
3359
- out.push(trimmed);
3264
+ const projectDirs = [];
3265
+ const seenProjectDirs = /* @__PURE__ */ new Set();
3266
+ for (const f of projectFiles) {
3267
+ const d = path4.dirname(f);
3268
+ if (!seenProjectDirs.has(d)) {
3269
+ seenProjectDirs.add(d);
3270
+ projectDirs.push(d);
3271
+ }
3360
3272
  }
3361
- return out;
3273
+ for (const dir of projectDirs.slice().reverse()) {
3274
+ layers.push(loadProjectFilesInDir(dir, hostApi));
3275
+ }
3276
+ if (hostApi === "v2") {
3277
+ const dirs = walkUp({ start: cwd, targets: [".opencode"], predicate: dirExists }).reverse();
3278
+ const extra = process.env.OPENCODE_CONFIG_DIR;
3279
+ if (extra && dirExists(extra)) layers.push(loadProjectFilesInDir(extra, hostApi));
3280
+ for (const dir of dirs) layers.push(loadProjectFilesInDir(dir, hostApi));
3281
+ } else {
3282
+ for (const dir of dotOpencodeDirs(cwd, worktree)) {
3283
+ layers.push(loadProjectFilesInDir(dir));
3284
+ }
3285
+ }
3286
+ return layers;
3362
3287
  }
3363
- function resolveReadOnly(settings, defaultProxyTools) {
3364
- const overridden = [];
3365
- if (settings.permissionMode !== void 0) {
3366
- overridden.push(
3367
- `permissionMode: "${settings.permissionMode}" is dropped; read-only sends --restricted instead, which is stricter than every mode except plan and incompatible with bypassPermissions`
3368
- );
3288
+ function loadMergedOpencodeConfig(cwd, hostApi = "v1") {
3289
+ let merged = {};
3290
+ for (const layer of opencodeConfigLayers(cwd, hostApi)) merged = mergeConfigLayer(merged, layer, hostApi);
3291
+ return merged;
3292
+ }
3293
+ function mergeOpencodeMcp(cwd, runtimeStatus, hostApi = "v1") {
3294
+ let merged = {};
3295
+ for (const layer of opencodeConfigLayers(cwd, hostApi)) {
3296
+ const servers = extractMcpBlock(layer);
3297
+ merged = hostApi === "v2" ? { ...merged, ...servers } : mergeMcp(merged, servers);
3369
3298
  }
3370
- if (settings.skipPermissions === true) {
3371
- overridden.push(
3372
- 'skipPermissions: forced to false; the CLI exits with "bypassPermissions not supported in restricted mode" when both are passed'
3373
- );
3299
+ if (runtimeStatus) {
3300
+ for (const name of Object.keys(merged)) {
3301
+ const status = runtimeStatus[name];
3302
+ if (status === void 0) continue;
3303
+ if (status === MCP_PENDING_STATUS) continue;
3304
+ const existing = merged[name];
3305
+ const base = existing && typeof existing === "object" ? existing : {};
3306
+ merged[name] = { ...base, enabled: status === "connected" };
3307
+ }
3374
3308
  }
3375
- if (settings.controlRequestBehavior === "allow") {
3376
- overridden.push(
3377
- 'controlRequestBehavior: forced to "deny"; read-only answers every permission request the same way the CLI does under --permission-prompts none'
3378
- );
3309
+ const enabledServerNames = [];
3310
+ for (const [name, spec] of Object.entries(merged)) {
3311
+ if (!spec || typeof spec !== "object") continue;
3312
+ const enabled = spec.enabled;
3313
+ if (enabled === false) continue;
3314
+ enabledServerNames.push(name);
3379
3315
  }
3380
- if (settings.controlRequestToolBehaviors !== void 0) {
3381
- overridden.push(
3382
- "controlRequestToolBehaviors: ignored; a per-tool allow would apply only on CLIs older than 2.1.263, so the preset would mean different things on different versions"
3383
- );
3316
+ const mergedBody = JSON.stringify({ mcpServers: merged }, null, 2);
3317
+ const hash = crypto2.createHash("sha256").update(mergedBody).digest("hex").slice(0, 12);
3318
+ return { servers: merged, enabledServerNames, hash };
3319
+ }
3320
+ function finishBridge(input) {
3321
+ const { servers, bridgedServerNames, allEnabledServerNames, hash, excludeServers } = input;
3322
+ if (Object.keys(servers).length === 0) {
3323
+ const allEnabledServersExcluded = excludeServers && allEnabledServerNames.length > 0 && allEnabledServerNames.every((name) => excludeServers.has(name));
3324
+ if (!allEnabledServersExcluded) return null;
3325
+ return {
3326
+ path: "",
3327
+ hash,
3328
+ serverNames: [],
3329
+ allEnabledServerNames
3330
+ };
3384
3331
  }
3385
- const requested = settings.proxyTools ?? defaultProxyTools;
3386
- const proxyTools = dedupe(requested).filter(
3387
- (name) => !READ_ONLY_DENIED_PROXY_TOOLS.has(name.toLowerCase())
3388
- );
3389
- const dropped = dedupe(requested).filter(
3390
- (name) => READ_ONLY_DENIED_PROXY_TOOLS.has(name.toLowerCase())
3332
+ const body = JSON.stringify({ mcpServers: servers }, null, 2);
3333
+ const bodyDigest = crypto2.createHash("sha256").update(body).digest("hex").slice(0, 12);
3334
+ const outPath = path4.join(
3335
+ pluginTmpDir(),
3336
+ `mcp-${bodyDigest}.json`
3391
3337
  );
3392
- if (dropped.length > 0) {
3393
- overridden.push(
3394
- `proxyTools: dropped ${dropped.join(", ")}; those execute in opencode, where no Claude CLI flag reaches them`
3395
- );
3338
+ try {
3339
+ if (!fileExists(outPath)) {
3340
+ fs3.writeFileSync(outPath, body, { encoding: "utf8", mode: 384 });
3341
+ }
3342
+ } catch (e) {
3343
+ log.warn("failed to write bridged MCP config", {
3344
+ error: e instanceof Error ? e.message : String(e)
3345
+ });
3346
+ return null;
3396
3347
  }
3348
+ log.info("bridged opencode MCP config", {
3349
+ target: outPath,
3350
+ hash,
3351
+ servers: bridgedServerNames,
3352
+ excluded: excludeServers ? Array.from(excludeServers) : []
3353
+ });
3397
3354
  return {
3398
- preset: "read-only",
3399
- permissionMode: READ_ONLY_PERMISSION_MODE,
3400
- skipPermissions: false,
3401
- proxyTools,
3402
- extraDisallowedTools: dedupe([
3403
- ...READ_ONLY_DISALLOWED_CLI_TOOLS,
3404
- ...settings.extraDisallowedTools ?? []
3405
- ]),
3406
- controlRequestBehavior: "deny",
3407
- controlRequestToolBehaviors: void 0,
3408
- overridden
3355
+ path: outPath,
3356
+ hash,
3357
+ serverNames: bridgedServerNames,
3358
+ allEnabledServerNames
3409
3359
  };
3410
3360
  }
3411
- function resolvePermissionPreset(settings, defaultProxyTools) {
3412
- const preset = settings.permissionPreset;
3413
- if (preset === void 0) return null;
3414
- if (preset === "read-only") return resolveReadOnly(settings, defaultProxyTools);
3415
- return { unknown: String(preset) };
3361
+
3362
+ // src/runtime-status.ts
3363
+ var opencodeClient = null;
3364
+ function setOpencodeClient(client) {
3365
+ if (client && typeof client === "object") {
3366
+ opencodeClient = client;
3367
+ }
3416
3368
  }
3417
- function isUnknownPreset(resolved) {
3418
- return resolved !== null && "unknown" in resolved;
3369
+ function getOpencodeClient() {
3370
+ return opencodeClient;
3419
3371
  }
3420
- var NO_PERMISSION_PRESET = "none";
3421
- function summarizePermissionPreset(provider, options, defaultProxyTools = DEFAULT_PROXY_TOOL_NAMES) {
3422
- const settings = options !== null && typeof options === "object" && !Array.isArray(options) ? options : {};
3423
- const resolved = resolvePermissionPreset(settings, defaultProxyTools);
3424
- if (resolved === null) {
3425
- return { provider, preset: NO_PERMISSION_PRESET, applied: false, overrides: [] };
3426
- }
3427
- if (isUnknownPreset(resolved)) {
3428
- return { provider, preset: resolved.unknown, applied: false, overrides: [] };
3429
- }
3430
- return {
3431
- provider,
3432
- preset: resolved.preset,
3433
- applied: true,
3434
- overrides: resolved.overridden
3435
- };
3372
+ var opencodeProjectDirectory;
3373
+ function setOpencodeProjectDirectory(dir) {
3374
+ opencodeProjectDirectory = dir;
3436
3375
  }
3437
- function isReadOnlyPermissionMode(mode) {
3438
- return mode === READ_ONLY_PERMISSION_MODE;
3376
+ function getOpencodeProjectDirectory() {
3377
+ return opencodeProjectDirectory;
3439
3378
  }
3440
-
3441
- // src/side-question.ts
3442
- import { randomUUID as randomUUID2 } from "crypto";
3443
- var MAX_HISTORY_EXCHANGES = 20;
3444
- var SIDE_QUESTION_USAGE = "Usage: /btw <question>. Ask a side question about the current conversation without adding it to the main context.";
3445
- var pendingProcesses = /* @__PURE__ */ new WeakSet();
3446
- function isRecord2(value) {
3447
- return value !== null && typeof value === "object" && !Array.isArray(value);
3379
+ function isUsableDirectory(d) {
3380
+ return typeof d === "string" && d.length > 1 && d !== "/";
3448
3381
  }
3449
- var SYSTEM_REMINDER_BLOCK = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
3450
- function parseSideQuestionContent(content) {
3451
- let text;
3452
- if (typeof content === "string") {
3453
- text = content;
3454
- } else if (Array.isArray(content)) {
3455
- const parts = [];
3456
- for (const part of content) {
3457
- if (!isRecord2(part) || part.type !== "text" || typeof part.text !== "string") return null;
3458
- parts.push(part.text);
3459
- }
3460
- text = parts.join("\n");
3461
- } else {
3462
- return null;
3463
- }
3464
- const match = /^\/btw(?:\s+([\s\S]*))?$/.exec(text.replace(SYSTEM_REMINDER_BLOCK, "").trim());
3465
- return match ? { question: (match[1] ?? "").trim() } : null;
3382
+ function resolveSpawnCwdFrom(configured, live, captured, sessionDir) {
3383
+ if (configured) return configured;
3384
+ if (isUsableDirectory(sessionDir)) return sessionDir;
3385
+ if (isUsableDirectory(live)) return live;
3386
+ return captured ?? live;
3466
3387
  }
3467
- function parseSideQuestion(prompt) {
3468
- const latest = prompt.at(-1);
3469
- return latest?.role === "user" ? parseSideQuestionContent(latest.content) : null;
3388
+ async function resolveSpawnCwdForSession(configured, sessionID) {
3389
+ if (configured) return configured;
3390
+ const sessionDir = sessionID ? await fetchSessionDirectory(sessionID) : void 0;
3391
+ return resolveSpawnCwdFrom(
3392
+ configured,
3393
+ process.cwd(),
3394
+ opencodeProjectDirectory,
3395
+ sessionDir
3396
+ );
3470
3397
  }
3471
- function assistantText(content) {
3472
- if (typeof content === "string") return content.trim();
3473
- if (!Array.isArray(content)) return "";
3474
- const parts = [];
3475
- for (const part of content) {
3476
- if (isRecord2(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text);
3398
+ async function fetchSessionDirectory(sessionID) {
3399
+ if (!sessionID || sessionID === "default") return void 0;
3400
+ const client = opencodeClient;
3401
+ if (!client?.session?.get) return void 0;
3402
+ try {
3403
+ const res = await client.session.get({ path: { id: sessionID } });
3404
+ const data = res.data;
3405
+ if (!data || typeof data !== "object") return void 0;
3406
+ const dir = data.directory;
3407
+ return isUsableDirectory(dir) ? dir : void 0;
3408
+ } catch (err) {
3409
+ log.warn("failed to fetch opencode session directory", {
3410
+ sessionID,
3411
+ error: err instanceof Error ? err.message : String(err)
3412
+ });
3413
+ return void 0;
3477
3414
  }
3478
- return parts.join("\n").trim();
3479
3415
  }
3480
- function collectSideQuestionHistory(prompt) {
3481
- const history = [];
3482
- for (let index = 0; index < prompt.length - 1; index++) {
3483
- const message = prompt[index];
3484
- if (message.role !== "user") continue;
3485
- const aside = parseSideQuestionContent(message.content);
3486
- if (!aside?.question) continue;
3487
- const reply = prompt[index + 1];
3488
- if (reply.role !== "assistant") continue;
3489
- const response = assistantText(reply.content);
3490
- if (!response || response === SIDE_QUESTION_USAGE) continue;
3491
- history.push({ question: aside.question, response });
3416
+ async function fetchSessionParentId(sessionID) {
3417
+ if (!sessionID || sessionID === "default") return void 0;
3418
+ const client = opencodeClient;
3419
+ if (!client?.session?.get) return void 0;
3420
+ try {
3421
+ const res = await client.session.get({ path: { id: sessionID } });
3422
+ const data = res.data;
3423
+ if (!data || typeof data !== "object") return void 0;
3424
+ const parentID = data.parentID;
3425
+ return typeof parentID === "string" && parentID.length > 0 ? parentID : void 0;
3426
+ } catch (err) {
3427
+ log.warn("failed to fetch opencode session parent", {
3428
+ sessionID,
3429
+ error: err instanceof Error ? err.message : String(err)
3430
+ });
3431
+ return void 0;
3492
3432
  }
3493
- return history.slice(-MAX_HISTORY_EXCHANGES);
3494
- }
3495
- function isSideQuestionPending(activeProcess) {
3496
- return pendingProcesses.has(activeProcess.proc);
3497
3433
  }
3498
- function dispatchSideQuestionResponse(activeProcess, line) {
3499
- if (!pendingProcesses.has(activeProcess.proc)) return false;
3500
- let message;
3434
+ async function fetchSessionRunState(sessionID) {
3435
+ if (!sessionID || sessionID === "default") return "unknown";
3436
+ const status = opencodeClient?.session?.status;
3437
+ if (!status) return "unknown";
3501
3438
  try {
3502
- message = JSON.parse(line);
3503
- } catch {
3504
- return false;
3439
+ const res = await status.call(opencodeClient.session);
3440
+ const data = res.data;
3441
+ if (!data || typeof data !== "object") return "unknown";
3442
+ const entry = data[sessionID];
3443
+ if (!entry || typeof entry !== "object") return "idle";
3444
+ const type = entry.type;
3445
+ return type === "idle" ? "idle" : "busy";
3446
+ } catch (err) {
3447
+ log.debug("failed to read opencode session status", {
3448
+ sessionID,
3449
+ error: err instanceof Error ? err.message : String(err)
3450
+ });
3451
+ return "unknown";
3505
3452
  }
3506
- if (!isRecord2(message) || message.type !== "control_response") return false;
3507
- const response = message.response;
3508
- if (!isRecord2(response) || typeof response.request_id !== "string") return false;
3509
- return activeProcess.lineEmitter.emit(`side-question:${response.request_id}`, response);
3510
3453
  }
3511
- async function requestSideQuestion(activeProcess, question, options) {
3512
- question = question.trim();
3513
- if (!question) return { response: SIDE_QUESTION_USAGE, synthetic: true };
3514
- options.abortSignal?.throwIfAborted();
3515
- const { proc, lineEmitter } = activeProcess;
3516
- if (options.interactive || !proc.stdout) {
3517
- throw new Error("/btw requires the headless Claude Code transport; interactive sessions are not supported.");
3518
- }
3519
- if (!cliSupportsSideQuestion(options.cliVersion)) {
3520
- throw new Error("/btw requires Claude Code CLI 2.1.258 or newer (the oldest verified version).");
3521
- }
3522
- if (pendingProcesses.has(proc)) {
3523
- throw new Error("Wait for the current /btw to finish before asking another.");
3524
- }
3525
- const stdin = proc.stdin;
3526
- if (proc.killed || proc.exitCode != null || proc.signalCode != null || !stdin || stdin.destroyed || stdin.writableEnded || !stdin.writable) {
3527
- throw new Error("/btw requires a live Claude Code session with writable stdin.");
3454
+ async function settleSessionRunState(sessionID, options = {}) {
3455
+ const pollMs = options.pollMs ?? 150;
3456
+ const windowMs = options.windowMs ?? 1500;
3457
+ const started = Date.now();
3458
+ let last = "unknown";
3459
+ for (; ; ) {
3460
+ const state = await fetchSessionRunState(sessionID);
3461
+ if (state === "busy") return "busy";
3462
+ if (state !== "unknown") last = state;
3463
+ if (Date.now() - started >= windowMs) return last;
3464
+ await new Promise((resolve6) => setTimeout(resolve6, pollMs));
3528
3465
  }
3529
- const timeoutMs = options.timeoutMs ?? 12e4;
3530
- if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) {
3531
- throw new Error("/btw timeoutMs must be a positive 32-bit integer.");
3466
+ }
3467
+ var DEFAULT_MCP_CONNECT_WAIT_MS = 3e3;
3468
+ var MCP_CONNECT_POLL_MS = 100;
3469
+ function resolveMcpConnectWaitMs(configured) {
3470
+ if (typeof configured !== "number" || !Number.isFinite(configured)) {
3471
+ return DEFAULT_MCP_CONNECT_WAIT_MS;
3532
3472
  }
3533
- const requestId = randomUUID2();
3534
- const request = JSON.stringify({
3535
- type: "control_request",
3536
- request_id: requestId,
3537
- request: {
3538
- subtype: "side_question",
3539
- question,
3540
- ...options.history === void 0 ? {} : { history: options.history }
3473
+ if (configured < 0) return DEFAULT_MCP_CONNECT_WAIT_MS;
3474
+ return configured;
3475
+ }
3476
+ function pendingMcpServers(status) {
3477
+ return Object.keys(status).filter(
3478
+ (name) => status[name] === MCP_PENDING_STATUS
3479
+ );
3480
+ }
3481
+ async function fetchRuntimeMcpStatus() {
3482
+ const client = opencodeClient;
3483
+ if (!client?.mcp?.status) return void 0;
3484
+ try {
3485
+ const res = await client.mcp.status();
3486
+ const data = res.data;
3487
+ if (!data || typeof data !== "object") return void 0;
3488
+ const out = {};
3489
+ for (const [name, entry] of Object.entries(data)) {
3490
+ if (entry && typeof entry === "object") {
3491
+ const status = entry.status;
3492
+ if (typeof status === "string") out[name] = status;
3493
+ }
3541
3494
  }
3495
+ return out;
3496
+ } catch (err) {
3497
+ log.warn("failed to fetch opencode MCP runtime status", {
3498
+ error: err instanceof Error ? err.message : String(err)
3499
+ });
3500
+ return void 0;
3501
+ }
3502
+ }
3503
+ async function getRuntimeMcpStatus(options = {}) {
3504
+ const current = await fetchRuntimeMcpStatus();
3505
+ const budgetMs = options.waitForPendingMs ?? 0;
3506
+ if (!current || budgetMs <= 0) return current;
3507
+ return waitForPendingMcpServers(current, budgetMs);
3508
+ }
3509
+ async function waitForPendingMcpServers(first, budgetMs) {
3510
+ let current = first;
3511
+ const waitedFor = pendingMcpServers(current);
3512
+ if (waitedFor.length === 0) return current;
3513
+ const started = Date.now();
3514
+ for (; ; ) {
3515
+ if (Date.now() - started >= budgetMs) break;
3516
+ await new Promise((resolve6) => setTimeout(resolve6, MCP_CONNECT_POLL_MS));
3517
+ const next = await fetchRuntimeMcpStatus();
3518
+ if (!next) break;
3519
+ current = next;
3520
+ if (pendingMcpServers(current).length === 0) break;
3521
+ }
3522
+ const stillPending = pendingMcpServers(current);
3523
+ log.info("waited for opencode MCP servers to finish connecting", {
3524
+ waitedFor,
3525
+ stillPending,
3526
+ budgetMs,
3527
+ elapsedMs: Date.now() - started
3542
3528
  });
3543
- pendingProcesses.add(proc);
3544
- return new Promise((resolve6, reject) => {
3545
- const event = `side-question:${requestId}`;
3546
- let settled = false;
3547
- let sent = false;
3548
- let cancelPending = false;
3549
- const cleanup = () => {
3550
- clearTimeout(timer);
3551
- lineEmitter.off(event, onResponse);
3552
- lineEmitter.off("close", onClose);
3553
- lineEmitter.off("error", onError);
3554
- proc.off("exit", onClose);
3555
- proc.off("close", onClose);
3556
- proc.off("error", onError);
3557
- if (!cancelPending) stdin.off("error", onError);
3558
- options.abortSignal?.removeEventListener("abort", onAbort);
3559
- pendingProcesses.delete(proc);
3560
- };
3561
- const fail = (error, cancel = false) => {
3562
- if (settled) return;
3563
- settled = true;
3564
- if (cancel && sent && !stdin.destroyed && !stdin.writableEnded && stdin.writable) {
3565
- try {
3566
- cancelPending = true;
3567
- stdin.write(
3568
- JSON.stringify({ type: "control_cancel_request", request_id: requestId }) + "\n",
3569
- () => {
3570
- queueMicrotask(() => stdin.off("error", onError));
3571
- }
3572
- );
3573
- } catch {
3574
- cancelPending = false;
3575
- }
3576
- }
3577
- cleanup();
3578
- reject(error);
3579
- };
3580
- const onClose = () => fail(new Error("Claude Code closed before answering /btw."));
3581
- const onError = (error) => fail(error);
3582
- const onAbort = () => fail(
3583
- options.abortSignal?.reason ?? new DOMException("/btw was aborted.", "AbortError"),
3584
- true
3585
- );
3586
- const onResponse = (response) => {
3587
- if (settled || response.request_id !== requestId) return;
3588
- if (response.subtype === "error") {
3589
- fail(new Error(typeof response.error === "string" ? response.error : "Claude Code rejected /btw."));
3590
- return;
3591
- }
3592
- const result = response.response;
3593
- if (response.subtype !== "success" || !isRecord2(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
3594
- fail(new Error("Claude Code returned an invalid /btw response."));
3595
- return;
3596
- }
3597
- settled = true;
3598
- cleanup();
3599
- resolve6({ response: result.response, synthetic: result.synthetic });
3600
- };
3601
- const timer = setTimeout(() => {
3602
- fail(new Error(`/btw timed out after ${timeoutMs}ms.`), true);
3603
- }, timeoutMs);
3604
- lineEmitter.on(event, onResponse);
3605
- lineEmitter.on("close", onClose);
3606
- lineEmitter.on("error", onError);
3607
- proc.on("exit", onClose);
3608
- proc.on("close", onClose);
3609
- proc.on("error", onError);
3610
- stdin.on("error", onError);
3611
- options.abortSignal?.addEventListener("abort", onAbort, { once: true });
3612
- if (options.abortSignal?.aborted) {
3613
- onAbort();
3614
- return;
3529
+ return current;
3530
+ }
3531
+ async function fetchOpencodeToolList(provider, model, directory) {
3532
+ const client = opencodeClient;
3533
+ if (!client?.tool?.list) return void 0;
3534
+ try {
3535
+ const res = await client.tool.list({
3536
+ query: { provider, model, ...directory ? { directory } : {} }
3537
+ });
3538
+ const data = res.data;
3539
+ if (!Array.isArray(data)) return void 0;
3540
+ const out = [];
3541
+ for (const entry of data) {
3542
+ if (!entry || typeof entry !== "object") continue;
3543
+ const e = entry;
3544
+ const id = typeof e.id === "string" ? e.id : null;
3545
+ const description = typeof e.description === "string" ? e.description : "";
3546
+ const parameters = e.parameters && typeof e.parameters === "object" ? e.parameters : {};
3547
+ if (!id) continue;
3548
+ out.push({ id, description, parameters });
3615
3549
  }
3616
- try {
3617
- sent = true;
3618
- stdin.write(request + "\n");
3619
- } catch (error) {
3620
- fail(error);
3550
+ return out;
3551
+ } catch (err) {
3552
+ log.warn("failed to fetch opencode tool list", {
3553
+ provider,
3554
+ model,
3555
+ error: err instanceof Error ? err.message : String(err)
3556
+ });
3557
+ return void 0;
3558
+ }
3559
+ }
3560
+ async function fetchSessionReplies(sessionID) {
3561
+ if (!sessionID || sessionID === "default") return void 0;
3562
+ const client = opencodeClient;
3563
+ if (!client?.session?.messages) return void 0;
3564
+ try {
3565
+ const res = await client.session.messages({ path: { id: sessionID } });
3566
+ const data = res.data;
3567
+ if (!Array.isArray(data)) return void 0;
3568
+ const out = [];
3569
+ for (const entry of data) {
3570
+ if (!entry || typeof entry !== "object") continue;
3571
+ const info = entry.info;
3572
+ if (!info || typeof info !== "object") continue;
3573
+ const record = info;
3574
+ const parts = entry.parts;
3575
+ const text = Array.isArray(parts) ? parts.flatMap((part) => {
3576
+ if (!part || typeof part !== "object") return [];
3577
+ const p = part;
3578
+ if (p.type !== "text" || typeof p.text !== "string") return [];
3579
+ return p.synthetic === true ? [] : [p.text];
3580
+ }).join("\n").trim() : "";
3581
+ const error = record.error;
3582
+ const message = error && typeof error === "object" ? typeof error.data?.message === "string" ? error.data.message : typeof error.name === "string" ? error.name : void 0 : void 0;
3583
+ out.push({
3584
+ role: typeof record.role === "string" ? record.role : "",
3585
+ completed: typeof record.time?.completed === "number",
3586
+ error: message,
3587
+ text
3588
+ });
3621
3589
  }
3622
- });
3590
+ return out;
3591
+ } catch (err) {
3592
+ log.warn("failed to fetch opencode session messages", {
3593
+ sessionID,
3594
+ error: err instanceof Error ? err.message : String(err)
3595
+ });
3596
+ return void 0;
3597
+ }
3598
+ }
3599
+ async function abortSession(sessionID) {
3600
+ if (!sessionID || sessionID === "default") return false;
3601
+ const client = opencodeClient;
3602
+ if (!client?.session?.abort) return false;
3603
+ try {
3604
+ const res = await client.session.abort({ path: { id: sessionID } });
3605
+ return res.data !== false;
3606
+ } catch (err) {
3607
+ log.warn("failed to abort opencode session", {
3608
+ sessionID,
3609
+ error: err instanceof Error ? err.message : String(err)
3610
+ });
3611
+ return false;
3612
+ }
3623
3613
  }
3624
3614
 
3625
- // src/session-manager.ts
3626
- function findActiveProcessBySessionId(sessionID) {
3627
- let found;
3628
- for (const ap of activeProcesses.values()) {
3629
- if (ap.opencodeSessionID === sessionID) found = ap;
3615
+ // src/background-tasks.ts
3616
+ var TASK_STATUS_TOOL_NAME = "task_status";
3617
+ var TASK_CANCEL_TOOL_NAME = "task_cancel";
3618
+ var MAX_LEDGERS = 32;
3619
+ var MAX_COLLECTED_PER_SESSION = 64;
3620
+ var collected = /* @__PURE__ */ new Map();
3621
+ var cancelled = /* @__PURE__ */ new Map();
3622
+ function ledgerIn(ledgers2, sessionKey2) {
3623
+ let ids = ledgers2.get(sessionKey2);
3624
+ if (!ids) {
3625
+ ids = /* @__PURE__ */ new Set();
3626
+ ledgers2.set(sessionKey2, ids);
3627
+ while (ledgers2.size > MAX_LEDGERS) {
3628
+ const oldest = ledgers2.keys().next();
3629
+ if (oldest.done) break;
3630
+ ledgers2.delete(oldest.value);
3631
+ }
3630
3632
  }
3631
- return found;
3633
+ return ids;
3632
3634
  }
3633
- var UNATTENDED_LINE_CAP = 500;
3634
- var UNATTENDED_BYTE_CAP = 2 * 1024 * 1024;
3635
- function bufferUnattendedLine(ap, line) {
3636
- const lines = ap.unattendedLines ??= [];
3637
- lines.push(line);
3638
- let bytes = 0;
3639
- for (const kept of lines) bytes += Buffer.byteLength(kept);
3640
- while (lines.length > 0 && (lines.length > UNATTENDED_LINE_CAP || bytes > UNATTENDED_BYTE_CAP)) {
3641
- bytes -= Buffer.byteLength(lines.shift());
3642
- ap.unattendedDropped = (ap.unattendedDropped ?? 0) + 1;
3635
+ function mark(ledgers2, sessionKey2, taskId) {
3636
+ const ids = ledgerIn(ledgers2, sessionKey2);
3637
+ ids.add(taskId);
3638
+ while (ids.size > MAX_COLLECTED_PER_SESSION) {
3639
+ const oldest = ids.values().next();
3640
+ if (oldest.done) break;
3641
+ ids.delete(oldest.value);
3643
3642
  }
3644
3643
  }
3645
- function takeUnattendedLines(ap) {
3646
- const lines = ap.unattendedLines ?? [];
3647
- const dropped = ap.unattendedDropped ?? 0;
3648
- ap.unattendedLines = [];
3649
- ap.unattendedDropped = 0;
3650
- return { lines, dropped };
3644
+ function markCollected(sessionKey2, taskId) {
3645
+ mark(collected, sessionKey2, taskId);
3651
3646
  }
3652
- var STDERR_RETAIN_BYTES = 2 * 1024;
3653
- function retainStderr(ap, chunk) {
3654
- ap.lastStderr = ((ap.lastStderr ?? "") + chunk).slice(-STDERR_RETAIN_BYTES);
3647
+ function hasCollectedBackgroundTask(sessionKey2, taskId) {
3648
+ return collected.get(sessionKey2)?.has(taskId) === true;
3655
3649
  }
3656
- function describeChildCrash(exitCode, signal, lastStderr) {
3657
- const how = signal ? `was killed by ${signal}` : typeof exitCode === "number" ? `exited with code ${exitCode}` : "closed its output";
3658
- const tail = lastStderr?.trim();
3659
- return `The Claude Code CLI ${how} before finishing this turn (no result was emitted), so the answer above may be incomplete.` + (tail ? `
3660
-
3661
- Last stderr from the CLI:
3662
- ${tail}` : "");
3650
+ function clearBackgroundTasks(sessionKey2) {
3651
+ collected.delete(sessionKey2);
3652
+ cancelled.delete(sessionKey2);
3663
3653
  }
3664
- var activeProcesses = /* @__PURE__ */ new Map();
3665
- var claudeSessions = /* @__PURE__ */ new Map();
3666
- var idleEvictionTimers = /* @__PURE__ */ new Map();
3667
- var MAX_IDLE_TIMEOUT_MS = 2147483647;
3668
- var DEFAULT_IDLE_PROCESS_TIMEOUT_MS = 0;
3669
- function resolveIdleProcessTimeoutMs(configured) {
3670
- return configured === void 0 ? DEFAULT_IDLE_PROCESS_TIMEOUT_MS : configured;
3654
+ var lastGate;
3655
+ function recordBackgroundSubagentGate(gate, now = Date.now()) {
3656
+ lastGate = { ...gate, at: now };
3671
3657
  }
3672
- var MAX_ACTIVE_PROCESSES = 16;
3673
- var PROCESS_EXIT_TIMEOUT_MS = 1500;
3674
- var PROCESS_FORCE_EXIT_TIMEOUT_MS = 500;
3675
- var CHILD_EXITED_MESSAGE = "Claude CLI subprocess closed before pending tool calls were resolved";
3676
- function envFlagEnabled(value) {
3677
- if (value === void 0) return false;
3678
- const normalized = value.trim().toLowerCase();
3679
- if (!normalized) return false;
3680
- return !["0", "false", "no", "off"].includes(normalized);
3658
+ function snapshotBackgroundSubagentGate() {
3659
+ return lastGate ? { ...lastGate } : void 0;
3681
3660
  }
3682
- function isClaudeThinkingDisabled() {
3683
- return envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_THINKING) || envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING);
3661
+ function snapshotBackgroundTasks() {
3662
+ const keys = /* @__PURE__ */ new Set([...collected.keys(), ...cancelled.keys()]);
3663
+ return [...keys].map((sessionKey2) => ({
3664
+ sessionKey: sessionKey2,
3665
+ collected: [...collected.get(sessionKey2) ?? []],
3666
+ cancelled: [...cancelled.get(sessionKey2) ?? []]
3667
+ }));
3684
3668
  }
3685
- function cliEffortLevel(effort) {
3686
- return effort === "minimal" ? "low" : effort;
3669
+ function readTaskId(input) {
3670
+ const raw = input.task_id ?? input.taskId ?? input.sessionID;
3671
+ if (typeof raw !== "string") return null;
3672
+ const id = raw.trim();
3673
+ return id.length > 0 ? id : null;
3687
3674
  }
3688
- function claudeSpawnEnv(opts) {
3689
- const env = {
3690
- ...process.env,
3691
- TERM: "xterm-256color",
3692
- // Pin the child to the binary whose version we detected, and keep it off
3693
- // non-essential network calls. Fills gaps only, so an explicit shell value
3694
- // survives: see `cliHygieneEnv` for why the version has to hold still.
3695
- ...cliHygieneEnv()
3696
- };
3697
- if (opts?.effort) {
3698
- env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort);
3699
- }
3700
- if (opts?.promptCacheTtl) {
3701
- env.CLAUDE_CODE_PROMPT_CACHE_TTL = opts.promptCacheTtl;
3702
- }
3703
- if (opts?.ignoreAnthropicApiKey) {
3704
- delete env.ANTHROPIC_API_KEY;
3705
- delete env.ANTHROPIC_AUTH_TOKEN;
3706
- }
3707
- if (!isClaudeThinkingDisabled() && process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES === void 0) {
3708
- env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = "1";
3675
+ var MISSING_TASK_ID = 'task_id is required: pass the background subagent\'s own opencode session id, from the `<task id="...">` envelope on opencode 1.x or the `sessionID` the background dispatch reported on opencode 2.';
3676
+ async function guardParent(taskId, callerSessionId) {
3677
+ if (!callerSessionId || callerSessionId === "default") {
3678
+ return `Cannot check that task_id ${taskId} belongs to this conversation (this turn has no opencode session id), so it is not inspected or cancelled.`;
3709
3679
  }
3710
- return env;
3680
+ const parent = await fetchSessionParentId(taskId);
3681
+ if (parent === callerSessionId) return null;
3682
+ return `task_id ${taskId} is not a subagent of this conversation, so it cannot be inspected or cancelled from here. Use the session id from a background dispatch this conversation itself made.`;
3711
3683
  }
3712
- function touch(key) {
3713
- const existing = activeProcesses.get(key);
3714
- if (existing) {
3715
- activeProcesses.delete(key);
3716
- activeProcesses.set(key, existing);
3684
+ function isBackgroundTaskRunning(runState, replies) {
3685
+ if (runState === "busy") return true;
3686
+ if (runState === "idle") return false;
3687
+ const reply = lastAssistantReply(replies);
3688
+ return reply !== void 0 && !reply.completed && reply.error === void 0;
3689
+ }
3690
+ function lastAssistantReply(replies) {
3691
+ for (let index = replies.length - 1; index >= 0; index--) {
3692
+ const reply = replies[index];
3693
+ if (reply.role === "assistant") return reply;
3717
3694
  }
3695
+ return void 0;
3718
3696
  }
3719
- function evictIfNeeded() {
3720
- while (activeProcesses.size >= MAX_ACTIVE_PROCESSES) {
3721
- let victimKey;
3722
- for (const [key, ap] of activeProcesses) {
3723
- if (!isTurnInFlight(ap)) {
3724
- victimKey = key;
3725
- break;
3726
- }
3727
- }
3728
- if (!victimKey) {
3729
- log.warn("every claude process is mid-turn; skipping LRU eviction", {
3730
- active: activeProcesses.size,
3731
- cap: MAX_ACTIVE_PROCESSES
3732
- });
3733
- return;
3734
- }
3735
- log.info("evicting LRU claude process", { sessionKey: victimKey });
3736
- deleteActiveProcess(victimKey);
3697
+ async function collectBackgroundTask(input, options) {
3698
+ const taskId = readTaskId(input);
3699
+ if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
3700
+ const refused = await guardParent(taskId, options.callerSessionId);
3701
+ if (refused) return { kind: "error", message: refused };
3702
+ const replies = await fetchSessionReplies(taskId);
3703
+ if (replies === void 0) {
3704
+ return {
3705
+ kind: "error",
3706
+ message: `Could not read background task ${taskId} from opencode. It may have been deleted, or this opencode build does not expose the session transcript. The completion notification is still the primary delivery path: end your turn and wait for it.`
3707
+ };
3737
3708
  }
3738
- }
3739
- var TURN_INTERRUPT_TIMEOUT_MS = 5e3;
3740
- function isTerminalResultLine(line) {
3741
- if (!line.includes('"result"')) return false;
3742
- try {
3743
- return JSON.parse(line).type === "result";
3744
- } catch {
3745
- return false;
3709
+ const runState = await fetchSessionRunState(taskId);
3710
+ if (isBackgroundTaskRunning(runState, replies)) {
3711
+ return {
3712
+ kind: "text",
3713
+ text: `<task id="${taskId}" state="running">
3714
+ Still working. Do not call this again in a loop: when it finishes opencode delivers the result to this conversation on its own. End your turn, or work on something that does not overlap it.
3715
+ </task>`
3716
+ };
3746
3717
  }
3747
- }
3748
- function settleTurn(ap) {
3749
- ap.turnInFlight = false;
3750
- const waiters = ap.turnIdleWaiters ?? [];
3751
- ap.turnIdleWaiters = [];
3752
- for (const wake of waiters) wake();
3753
- }
3754
- function noteTurnStarted(ap) {
3755
- if (ap.asideTransport?.interactive) return;
3756
- ap.turnInFlight = true;
3757
- }
3758
- function noteTurnLine(ap, line) {
3759
- if (!ap.turnInFlight) return;
3760
- if (isTerminalResultLine(line)) settleTurn(ap);
3761
- }
3762
- function isTurnInFlight(ap) {
3763
- return ap.turnInFlight === true;
3764
- }
3765
- function awaitTurnIdle(ap, timeoutMs) {
3766
- if (!ap.turnInFlight) return Promise.resolve(true);
3767
- return new Promise((resolve6) => {
3768
- const wake = () => {
3769
- clearTimeout(timer);
3770
- resolve6(true);
3718
+ const reply = lastAssistantReply(replies);
3719
+ if (!reply) {
3720
+ return {
3721
+ kind: "text",
3722
+ text: `<task id="${taskId}" state="unknown">
3723
+ opencode has no assistant reply for this task yet and it is not running. It may still be starting up.
3724
+ </task>`
3771
3725
  };
3772
- const timer = setTimeout(() => {
3773
- const waiters = ap.turnIdleWaiters ?? [];
3774
- const at = waiters.indexOf(wake);
3775
- if (at >= 0) waiters.splice(at, 1);
3776
- resolve6(false);
3777
- }, timeoutMs);
3778
- (ap.turnIdleWaiters ??= []).push(wake);
3779
- });
3780
- }
3781
- function interruptTurn(ap, timeoutMs = TURN_INTERRUPT_TIMEOUT_MS) {
3782
- if (!ap.turnInFlight) return Promise.resolve(true);
3783
- const stdin = ap.proc.stdin;
3784
- if (ap.asideTransport?.interactive || !stdin || !stdin.writable) {
3785
- log.notice("cannot interrupt this transport; waiting for the turn to end");
3786
- return awaitTurnIdle(ap, timeoutMs);
3787
3726
  }
3788
- try {
3789
- stdin.write(
3790
- JSON.stringify({
3791
- type: "control_request",
3792
- request_id: randomUUID3(),
3793
- request: { subtype: "interrupt" }
3794
- }) + "\n"
3795
- );
3796
- } catch (error) {
3797
- log.warn("failed to write interrupt control request", {
3798
- error: error instanceof Error ? error.message : String(error)
3799
- });
3800
- return Promise.resolve(false);
3727
+ if (reply.error) {
3728
+ markCollected(options.sessionKey, taskId);
3729
+ return {
3730
+ kind: "text",
3731
+ isError: true,
3732
+ text: `<task id="${taskId}" state="error">
3733
+ ${reply.error}
3734
+ </task>`
3735
+ };
3801
3736
  }
3802
- return awaitTurnIdle(ap, timeoutMs);
3803
- }
3804
- function cancelIdleProcessEviction(key) {
3805
- const timer = idleEvictionTimers.get(key);
3806
- if (!timer) return;
3807
- clearTimeout(timer);
3808
- idleEvictionTimers.delete(key);
3809
- }
3810
- function getActiveProcess(key) {
3811
- const ap = activeProcesses.get(key);
3812
- if (ap) {
3813
- cancelIdleProcessEviction(key);
3814
- touch(key);
3737
+ if (hasCollectedBackgroundTask(options.sessionKey, taskId)) {
3738
+ return {
3739
+ kind: "text",
3740
+ text: `<task id="${taskId}" state="completed">
3741
+ Already delivered to this conversation earlier (by this tool or by opencode's completion notification). Scroll back for the result rather than collecting it again.
3742
+ </task>`
3743
+ };
3815
3744
  }
3816
- return ap;
3817
- }
3818
- function setActiveProcess(key, ap) {
3819
- cancelIdleProcessEviction(key);
3820
- activeProcesses.set(key, ap);
3745
+ markCollected(options.sessionKey, taskId);
3746
+ log.info("collected background subagent result", {
3747
+ sessionKey: options.sessionKey,
3748
+ taskId,
3749
+ textLength: reply.text.length
3750
+ });
3751
+ return {
3752
+ kind: "text",
3753
+ text: `<task id="${taskId}" state="completed">
3754
+ <task_result>
3755
+ ${reply.text || "(the subagent produced no text)"}
3756
+ </task_result>
3757
+ </task>`
3758
+ };
3821
3759
  }
3822
- function scheduleIdleProcessEviction(key, timeoutMs) {
3823
- cancelIdleProcessEviction(key);
3824
- if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_IDLE_TIMEOUT_MS) {
3825
- return;
3760
+ async function cancelBackgroundTask(input, options) {
3761
+ const taskId = readTaskId(input);
3762
+ if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
3763
+ const refused = await guardParent(taskId, options.callerSessionId);
3764
+ if (refused) return { kind: "error", message: refused };
3765
+ const running = isBackgroundTaskRunning(
3766
+ await fetchSessionRunState(taskId),
3767
+ await fetchSessionReplies(taskId) ?? []
3768
+ );
3769
+ const aborted = await abortSession(taskId);
3770
+ if (!aborted) {
3771
+ return {
3772
+ kind: "error",
3773
+ message: `Could not cancel background task ${taskId}: opencode did not accept the abort. It may have already finished, or this opencode build does not expose session abort.`
3774
+ };
3826
3775
  }
3827
- const scheduledProcess = activeProcesses.get(key);
3828
- if (!scheduledProcess) return;
3829
- const timer = setTimeout(() => {
3830
- idleEvictionTimers.delete(key);
3831
- if (activeProcesses.get(key) !== scheduledProcess) return;
3832
- if (isTurnInFlight(scheduledProcess)) {
3833
- log.info("idle timer found a turn in flight; re-arming", { sessionKey: key, timeoutMs });
3834
- scheduleIdleProcessEviction(key, timeoutMs);
3835
- return;
3836
- }
3837
- log.info("evicting idle claude process", { sessionKey: key, timeoutMs });
3838
- deleteActiveProcess(key);
3839
- }, timeoutMs);
3840
- timer.unref();
3841
- idleEvictionTimers.set(key, timer);
3776
+ collected.get(options.sessionKey)?.delete(taskId);
3777
+ mark(cancelled, options.sessionKey, taskId);
3778
+ log.info("cancelled background subagent", {
3779
+ sessionKey: options.sessionKey,
3780
+ taskId,
3781
+ wasRunning: running
3782
+ });
3783
+ return {
3784
+ kind: "text",
3785
+ text: `<task id="${taskId}" state="cancelled">
3786
+ ` + (running ? "Stopped. No completion notification will arrive for it." : "It was not running when the cancel was sent, so nothing was interrupted. No completion notification will arrive for it.") + "\n</task>"
3787
+ };
3842
3788
  }
3843
- function detachActiveProcess(key) {
3844
- cancelIdleProcessEviction(key);
3845
- const ap = activeProcesses.get(key);
3846
- if (!ap) return void 0;
3847
- activeProcesses.delete(key);
3848
- if (ap.proxyServer) {
3849
- void ap.proxyServer.close();
3850
- rejectAllPendingProxyCallsForSession(key, new Error(SERVER_CLOSED_MESSAGE));
3789
+
3790
+ // src/permission-presets.ts
3791
+ var READ_ONLY_DISALLOWED_CLI_TOOLS = [
3792
+ "Bash",
3793
+ "Write",
3794
+ "Edit",
3795
+ "NotebookEdit",
3796
+ "REPL",
3797
+ "JavaScript",
3798
+ "WebFetch"
3799
+ ];
3800
+ var READ_ONLY_DENIED_PROXY_TOOLS = /* @__PURE__ */ new Set([
3801
+ "bash",
3802
+ "write",
3803
+ "edit",
3804
+ "webfetch",
3805
+ "task",
3806
+ "task_batch"
3807
+ ]);
3808
+ function dedupe(names) {
3809
+ const out = [];
3810
+ const seen = /* @__PURE__ */ new Set();
3811
+ for (const name of names) {
3812
+ const trimmed = String(name).trim();
3813
+ if (!trimmed || seen.has(trimmed)) continue;
3814
+ seen.add(trimmed);
3815
+ out.push(trimmed);
3851
3816
  }
3852
- return ap;
3817
+ return out;
3853
3818
  }
3854
- function deleteActiveProcessesForSession(sessionID) {
3855
- if (!sessionID || sessionID === "default") return [];
3856
- const released = [];
3857
- for (const [key, ap] of [...activeProcesses]) {
3858
- const owned = ap.opencodeSessionID === sessionID || describeSessionKey(key).session === sessionID;
3859
- if (!owned) continue;
3860
- log.info("releasing claude process for deleted session", { sessionKey: key, sessionID });
3861
- void deleteActiveProcessAndWait(key);
3862
- released.push(key);
3863
- }
3864
- for (const key of [...claudeSessions.keys()]) {
3865
- if (describeSessionKey(key).session !== sessionID) continue;
3866
- deleteClaudeSessionId(key);
3867
- clearCompression(key);
3868
- if (!released.includes(key)) released.push(key);
3819
+ function resolveReadOnly(settings, defaultProxyTools) {
3820
+ const overridden = [];
3821
+ if (settings.permissionMode !== void 0) {
3822
+ overridden.push(
3823
+ `permissionMode: "${settings.permissionMode}" is dropped; read-only sends --restricted instead, which is stricter than every mode except plan and incompatible with bypassPermissions`
3824
+ );
3869
3825
  }
3870
- for (const key of released) clearBackgroundTasks(key);
3871
- return released;
3872
- }
3873
- function killAllActiveProcesses() {
3874
- const keys = [...activeProcesses.keys()];
3875
- for (const key of keys) deleteActiveProcess(key);
3876
- for (const key of keys) clearBackgroundTasks(key);
3877
- return keys;
3826
+ if (settings.skipPermissions === true) {
3827
+ overridden.push(
3828
+ 'skipPermissions: forced to false; the CLI exits with "bypassPermissions not supported in restricted mode" when both are passed'
3829
+ );
3830
+ }
3831
+ if (settings.controlRequestBehavior === "allow") {
3832
+ overridden.push(
3833
+ 'controlRequestBehavior: forced to "deny"; read-only answers every permission request the same way the CLI does under --permission-prompts none'
3834
+ );
3835
+ }
3836
+ if (settings.controlRequestToolBehaviors !== void 0) {
3837
+ overridden.push(
3838
+ "controlRequestToolBehaviors: ignored; a per-tool allow would apply only on CLIs older than 2.1.263, so the preset would mean different things on different versions"
3839
+ );
3840
+ }
3841
+ const requested = settings.proxyTools ?? defaultProxyTools;
3842
+ const proxyTools = dedupe(requested).filter(
3843
+ (name) => !READ_ONLY_DENIED_PROXY_TOOLS.has(name.toLowerCase())
3844
+ );
3845
+ const dropped = dedupe(requested).filter(
3846
+ (name) => READ_ONLY_DENIED_PROXY_TOOLS.has(name.toLowerCase())
3847
+ );
3848
+ if (dropped.length > 0) {
3849
+ overridden.push(
3850
+ `proxyTools: dropped ${dropped.join(", ")}; those execute in opencode, where no Claude CLI flag reaches them`
3851
+ );
3852
+ }
3853
+ return {
3854
+ preset: "read-only",
3855
+ permissionMode: READ_ONLY_PERMISSION_MODE,
3856
+ skipPermissions: false,
3857
+ proxyTools,
3858
+ extraDisallowedTools: dedupe([
3859
+ ...READ_ONLY_DISALLOWED_CLI_TOOLS,
3860
+ ...settings.extraDisallowedTools ?? []
3861
+ ]),
3862
+ controlRequestBehavior: "deny",
3863
+ controlRequestToolBehaviors: void 0,
3864
+ overridden
3865
+ };
3878
3866
  }
3879
- var processExitCleanupWired = false;
3880
- function ensureProcessExitCleanup() {
3881
- if (processExitCleanupWired) return false;
3882
- processExitCleanupWired = true;
3883
- process.once("exit", () => {
3884
- killAllActiveProcesses();
3885
- });
3886
- return true;
3867
+ function resolvePermissionPreset(settings, defaultProxyTools) {
3868
+ const preset = settings.permissionPreset;
3869
+ if (preset === void 0) return null;
3870
+ if (preset === "read-only") return resolveReadOnly(settings, defaultProxyTools);
3871
+ return { unknown: String(preset) };
3887
3872
  }
3888
- function deleteActiveProcess(key) {
3889
- const ap = detachActiveProcess(key);
3890
- ap?.proc.kill();
3873
+ function isUnknownPreset(resolved) {
3874
+ return resolved !== null && "unknown" in resolved;
3891
3875
  }
3892
- function hasProcessExited(proc) {
3893
- return proc.exitCode !== null || proc.signalCode !== null;
3876
+ var NO_PERMISSION_PRESET = "none";
3877
+ function summarizePermissionPreset(provider, options, defaultProxyTools = DEFAULT_PROXY_TOOL_NAMES) {
3878
+ const settings = options !== null && typeof options === "object" && !Array.isArray(options) ? options : {};
3879
+ const resolved = resolvePermissionPreset(settings, defaultProxyTools);
3880
+ if (resolved === null) {
3881
+ return { provider, preset: NO_PERMISSION_PRESET, applied: false, overrides: [] };
3882
+ }
3883
+ if (isUnknownPreset(resolved)) {
3884
+ return { provider, preset: resolved.unknown, applied: false, overrides: [] };
3885
+ }
3886
+ return {
3887
+ provider,
3888
+ preset: resolved.preset,
3889
+ applied: true,
3890
+ overrides: resolved.overridden
3891
+ };
3894
3892
  }
3895
- function waitForProcessExit(proc, timeoutMs) {
3896
- if (hasProcessExited(proc)) return Promise.resolve(true);
3897
- return new Promise((resolve6) => {
3898
- const onExit = () => {
3899
- clearTimeout(timer);
3900
- resolve6(true);
3901
- };
3902
- const timer = setTimeout(() => {
3903
- proc.off("exit", onExit);
3904
- resolve6(hasProcessExited(proc));
3905
- }, timeoutMs);
3906
- proc.once("exit", onExit);
3907
- });
3893
+ function isReadOnlyPermissionMode(mode) {
3894
+ return mode === READ_ONLY_PERMISSION_MODE;
3908
3895
  }
3909
- async function deleteActiveProcessAndWait(key, options = {}) {
3910
- const ap = detachActiveProcess(key);
3911
- if (!ap || hasProcessExited(ap.proc)) return true;
3912
- const gracefulExit = waitForProcessExit(
3913
- ap.proc,
3914
- options.exitTimeoutMs ?? PROCESS_EXIT_TIMEOUT_MS
3915
- );
3916
- ap.proc.kill();
3917
- if (await gracefulExit) return true;
3918
- const forcedExit = waitForProcessExit(
3919
- ap.proc,
3920
- options.forceExitTimeoutMs ?? PROCESS_FORCE_EXIT_TIMEOUT_MS
3921
- );
3922
- ap.proc.kill("SIGKILL");
3923
- if (await forcedExit) return true;
3924
- log.warn("claude process did not exit; starting a fresh session", {
3925
- sessionKey: key
3926
- });
3927
- deleteClaudeSessionId(key);
3928
- return false;
3896
+
3897
+ // src/side-question.ts
3898
+ import { randomUUID as randomUUID2 } from "crypto";
3899
+ var MAX_HISTORY_EXCHANGES = 20;
3900
+ var SIDE_QUESTION_USAGE = "Usage: /btw <question>. Ask a side question about the current conversation without adding it to the main context.";
3901
+ var pendingProcesses = /* @__PURE__ */ new WeakSet();
3902
+ function isRecord2(value) {
3903
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3929
3904
  }
3930
- function getClaudeSessionId(key) {
3931
- return claudeSessions.get(key);
3905
+ var SYSTEM_REMINDER_BLOCK = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
3906
+ function parseSideQuestionContent(content) {
3907
+ let text;
3908
+ if (typeof content === "string") {
3909
+ text = content;
3910
+ } else if (Array.isArray(content)) {
3911
+ const parts = [];
3912
+ for (const part of content) {
3913
+ if (!isRecord2(part) || part.type !== "text" || typeof part.text !== "string") return null;
3914
+ parts.push(part.text);
3915
+ }
3916
+ text = parts.join("\n");
3917
+ } else {
3918
+ return null;
3919
+ }
3920
+ const match = /^\/btw(?:\s+([\s\S]*))?$/.exec(text.replace(SYSTEM_REMINDER_BLOCK, "").trim());
3921
+ return match ? { question: (match[1] ?? "").trim() } : null;
3932
3922
  }
3933
- var MAX_CLAUDE_SESSION_ENTRIES = 64;
3934
- function claudeSessionIsBusy(key) {
3935
- if (activeProcesses.has(key)) return true;
3936
- return getPendingProxyCalls(key).length > 0 || hasExitPlanModeQuestions(key);
3923
+ function parseSideQuestion(prompt) {
3924
+ const latest = prompt.at(-1);
3925
+ return latest?.role === "user" ? parseSideQuestionContent(latest.content) : null;
3937
3926
  }
3938
- function capClaudeSessions() {
3939
- for (const key of [...claudeSessions.keys()]) {
3940
- if (claudeSessions.size <= MAX_CLAUDE_SESSION_ENTRIES) return;
3941
- if (claudeSessionIsBusy(key)) continue;
3942
- log.info("claude session cap reached; releasing an idle session", {
3943
- sessionKey: key,
3944
- size: claudeSessions.size,
3945
- cap: MAX_CLAUDE_SESSION_ENTRIES
3946
- });
3947
- deleteClaudeSessionId(key);
3927
+ function assistantText(content) {
3928
+ if (typeof content === "string") return content.trim();
3929
+ if (!Array.isArray(content)) return "";
3930
+ const parts = [];
3931
+ for (const part of content) {
3932
+ if (isRecord2(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text);
3948
3933
  }
3934
+ return parts.join("\n").trim();
3949
3935
  }
3950
- function setClaudeSessionId(key, sessionId) {
3951
- claudeSessions.delete(key);
3952
- claudeSessions.set(key, sessionId);
3953
- capClaudeSessions();
3936
+ function collectSideQuestionHistory(prompt) {
3937
+ const history = [];
3938
+ for (let index = 0; index < prompt.length - 1; index++) {
3939
+ const message = prompt[index];
3940
+ if (message.role !== "user") continue;
3941
+ const aside = parseSideQuestionContent(message.content);
3942
+ if (!aside?.question) continue;
3943
+ const reply = prompt[index + 1];
3944
+ if (reply.role !== "assistant") continue;
3945
+ const response = assistantText(reply.content);
3946
+ if (!response || response === SIDE_QUESTION_USAGE) continue;
3947
+ history.push({ question: aside.question, response });
3948
+ }
3949
+ return history.slice(-MAX_HISTORY_EXCHANGES);
3954
3950
  }
3955
- function deleteClaudeSessionId(key) {
3956
- clearExitPlanModeQuestions(key);
3957
- clearAccountFailoverQuestions(key);
3958
- const claudeSessionId = claudeSessions.get(key);
3959
- if (claudeSessionId) clearLedger(claudeSessionId);
3960
- claudeSessions.delete(key);
3951
+ function isSideQuestionPending(activeProcess) {
3952
+ return pendingProcesses.has(activeProcess.proc);
3961
3953
  }
3962
- function effortSessionKey(baseKey, effort) {
3963
- return effort ? `${baseKey}::effort=${effort}` : baseKey;
3954
+ function dispatchSideQuestionResponse(activeProcess, line) {
3955
+ if (!pendingProcesses.has(activeProcess.proc)) return false;
3956
+ let message;
3957
+ try {
3958
+ message = JSON.parse(line);
3959
+ } catch {
3960
+ return false;
3961
+ }
3962
+ if (!isRecord2(message) || message.type !== "control_response") return false;
3963
+ const response = message.response;
3964
+ if (!isRecord2(response) || typeof response.request_id !== "string") return false;
3965
+ return activeProcess.lineEmitter.emit(`side-question:${response.request_id}`, response);
3964
3966
  }
3965
- function invalidateOtherEffortSessions(baseKey, effort) {
3966
- const levels = [
3967
- void 0,
3968
- "minimal",
3969
- "low",
3970
- "medium",
3971
- "high",
3972
- "xhigh",
3973
- "max"
3974
- ];
3975
- const staleKeys = levels.filter((level) => level !== effort).map((level) => effortSessionKey(baseKey, level));
3976
- for (const key of staleKeys) {
3977
- const active = activeProcesses.get(key);
3978
- if (getPendingProxyCalls(key).length || hasExitPlanModeQuestions(key) || active?.pendingProxyCompletions?.size || active && (active.lineEmitter.listenerCount("line") > 0 || isSideQuestionPending(active))) {
3979
- throw new Error(
3980
- "Cannot change reasoning effort while the previous effort session has pending work. Finish that work at its original effort first."
3981
- );
3982
- }
3967
+ async function requestSideQuestion(activeProcess, question, options) {
3968
+ question = question.trim();
3969
+ if (!question) return { response: SIDE_QUESTION_USAGE, synthetic: true };
3970
+ options.abortSignal?.throwIfAborted();
3971
+ const { proc, lineEmitter } = activeProcess;
3972
+ if (options.interactive || !proc.stdout) {
3973
+ throw new Error("/btw requires the headless Claude Code transport; interactive sessions are not supported.");
3983
3974
  }
3984
- for (const key of staleKeys) {
3985
- deleteActiveProcess(key);
3986
- deleteClaudeSessionId(key);
3987
- clearCompression(key);
3988
- clearBackgroundTasks(key);
3975
+ if (!cliSupportsSideQuestion(options.cliVersion)) {
3976
+ throw new Error("/btw requires Claude Code CLI 2.1.258 or newer (the oldest verified version).");
3989
3977
  }
3990
- }
3991
- function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort, promptCacheTtl) {
3992
- evictIfNeeded();
3993
- log.info("spawning new claude process", {
3994
- cliPath,
3995
- cliArgs,
3996
- cwd,
3997
- sessionKey: sessionKey2,
3998
- effort,
3999
- promptCacheTtl
4000
- });
4001
- const proc = spawn(cliPath, cliArgs, {
4002
- cwd,
4003
- stdio: ["pipe", "pipe", "pipe"],
4004
- env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort, promptCacheTtl }),
4005
- shell: process.platform === "win32"
4006
- });
4007
- const lineEmitter = new EventEmitter3();
4008
- const ap = {
4009
- proc,
4010
- lineEmitter,
4011
- proxyServer: proxyServer ?? null,
4012
- mcpHash,
4013
- systemPromptFile,
4014
- effort,
4015
- promptCacheTtl,
4016
- startedAt: Date.now(),
4017
- cliPath,
4018
- cliArgs: [...cliArgs],
4019
- unattendedLines: [],
4020
- unattendedDropped: 0
4021
- };
4022
- const rl = createInterface({ input: proc.stdout });
4023
- rl.on("line", (line) => {
4024
- if (dispatchSideQuestionResponse(ap, line)) return;
4025
- noteTurnLine(ap, line);
4026
- if (lineEmitter.listenerCount("line") === 0) {
4027
- bufferUnattendedLine(ap, line);
4028
- return;
3978
+ if (pendingProcesses.has(proc)) {
3979
+ throw new Error("Wait for the current /btw to finish before asking another.");
3980
+ }
3981
+ const stdin = proc.stdin;
3982
+ if (proc.killed || proc.exitCode != null || proc.signalCode != null || !stdin || stdin.destroyed || stdin.writableEnded || !stdin.writable) {
3983
+ throw new Error("/btw requires a live Claude Code session with writable stdin.");
3984
+ }
3985
+ const timeoutMs = options.timeoutMs ?? 12e4;
3986
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) {
3987
+ throw new Error("/btw timeoutMs must be a positive 32-bit integer.");
3988
+ }
3989
+ const requestId = randomUUID2();
3990
+ const request = JSON.stringify({
3991
+ type: "control_request",
3992
+ request_id: requestId,
3993
+ request: {
3994
+ subtype: "side_question",
3995
+ question,
3996
+ ...options.history === void 0 ? {} : { history: options.history }
4029
3997
  }
4030
- lineEmitter.emit("line", line);
4031
- });
4032
- rl.on("close", () => {
4033
- settleTurn(ap);
4034
- lineEmitter.emit("close");
4035
- });
4036
- cancelIdleProcessEviction(sessionKey2);
4037
- activeProcesses.set(sessionKey2, ap);
4038
- proc.on("error", (err) => {
4039
- log.error("claude process error", { sessionKey: sessionKey2, error: err.message });
4040
- });
4041
- proc.stdin?.on("error", (err) => {
4042
- log.warn("claude process stdin error", {
4043
- sessionKey: sessionKey2,
4044
- code: err.code,
4045
- error: err.message
4046
- });
4047
- settleTurn(ap);
4048
3998
  });
4049
- proc.on("exit", (code, signal) => {
4050
- log.info("claude process exited", { code, signal, sessionKey: sessionKey2 });
4051
- void proxyServer?.close();
4052
- if (systemPromptFile) {
4053
- void unlink(systemPromptFile).catch(() => {
4054
- });
4055
- }
4056
- const ownsSessionKey = activeProcesses.get(sessionKey2) === ap;
4057
- if (ownsSessionKey) {
4058
- cancelIdleProcessEviction(sessionKey2);
4059
- activeProcesses.delete(sessionKey2);
4060
- if (getPendingProxyCalls(sessionKey2).length > 0) {
4061
- rejectAllPendingProxyCallsForSession(
4062
- sessionKey2,
4063
- new Error(CHILD_EXITED_MESSAGE)
4064
- );
3999
+ pendingProcesses.add(proc);
4000
+ return new Promise((resolve6, reject) => {
4001
+ const event = `side-question:${requestId}`;
4002
+ let settled = false;
4003
+ let sent = false;
4004
+ let cancelPending = false;
4005
+ const cleanup = () => {
4006
+ clearTimeout(timer);
4007
+ lineEmitter.off(event, onResponse);
4008
+ lineEmitter.off("close", onClose);
4009
+ lineEmitter.off("error", onError);
4010
+ proc.off("exit", onClose);
4011
+ proc.off("close", onClose);
4012
+ proc.off("error", onError);
4013
+ if (!cancelPending) stdin.off("error", onError);
4014
+ options.abortSignal?.removeEventListener("abort", onAbort);
4015
+ pendingProcesses.delete(proc);
4016
+ };
4017
+ const fail = (error, cancel = false) => {
4018
+ if (settled) return;
4019
+ settled = true;
4020
+ if (cancel && sent && !stdin.destroyed && !stdin.writableEnded && stdin.writable) {
4021
+ try {
4022
+ cancelPending = true;
4023
+ stdin.write(
4024
+ JSON.stringify({ type: "control_cancel_request", request_id: requestId }) + "\n",
4025
+ () => {
4026
+ queueMicrotask(() => stdin.off("error", onError));
4027
+ }
4028
+ );
4029
+ } catch {
4030
+ cancelPending = false;
4031
+ }
4065
4032
  }
4066
- }
4067
- if (ownsSessionKey && code !== 0 && code !== null) {
4068
- log.info("process exited with error, clearing session", {
4069
- code,
4070
- sessionKey: sessionKey2
4071
- });
4072
- claudeSessions.delete(sessionKey2);
4073
- }
4074
- });
4075
- proc.stderr?.on("data", (data) => {
4076
- const stderr = data.toString();
4077
- log.debug("stderr", { data: stderr.slice(0, 200) });
4078
- retainStderr(ap, stderr);
4079
- reportUnrecognizedModel(stderr);
4080
- if (stderr.includes("No conversation found") || stderr.includes("Session ID") && (stderr.includes("already in use") || stderr.includes("not found") || stderr.includes("invalid"))) {
4081
- if (activeProcesses.get(sessionKey2) === ap) {
4082
- log.warn("claude session ID error, clearing session", {
4083
- sessionKey: sessionKey2,
4084
- error: stderr.slice(0, 200)
4085
- });
4086
- claudeSessions.delete(sessionKey2);
4087
- } else {
4088
- log.debug("ignoring session ID error from stale claude process", {
4089
- sessionKey: sessionKey2
4090
- });
4033
+ cleanup();
4034
+ reject(error);
4035
+ };
4036
+ const onClose = () => fail(new Error("Claude Code closed before answering /btw."));
4037
+ const onError = (error) => fail(error);
4038
+ const onAbort = () => fail(
4039
+ options.abortSignal?.reason ?? new DOMException("/btw was aborted.", "AbortError"),
4040
+ true
4041
+ );
4042
+ const onResponse = (response) => {
4043
+ if (settled || response.request_id !== requestId) return;
4044
+ if (response.subtype === "error") {
4045
+ fail(new Error(typeof response.error === "string" ? response.error : "Claude Code rejected /btw."));
4046
+ return;
4047
+ }
4048
+ const result = response.response;
4049
+ if (response.subtype !== "success" || !isRecord2(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
4050
+ fail(new Error("Claude Code returned an invalid /btw response."));
4051
+ return;
4091
4052
  }
4053
+ settled = true;
4054
+ cleanup();
4055
+ resolve6({ response: result.response, synthetic: result.synthetic });
4056
+ };
4057
+ const timer = setTimeout(() => {
4058
+ fail(new Error(`/btw timed out after ${timeoutMs}ms.`), true);
4059
+ }, timeoutMs);
4060
+ lineEmitter.on(event, onResponse);
4061
+ lineEmitter.on("close", onClose);
4062
+ lineEmitter.on("error", onError);
4063
+ proc.on("exit", onClose);
4064
+ proc.on("close", onClose);
4065
+ proc.on("error", onError);
4066
+ stdin.on("error", onError);
4067
+ options.abortSignal?.addEventListener("abort", onAbort, { once: true });
4068
+ if (options.abortSignal?.aborted) {
4069
+ onAbort();
4070
+ return;
4071
+ }
4072
+ try {
4073
+ sent = true;
4074
+ stdin.write(request + "\n");
4075
+ } catch (error) {
4076
+ fail(error);
4092
4077
  }
4093
4078
  });
4094
- return ap;
4095
4079
  }
4096
- function appendResumeIfNeeded(sessionKey2, cliArgs) {
4097
- if (cliArgs.includes("--resume") || cliArgs.includes("--session-id")) {
4098
- return cliArgs;
4080
+
4081
+ // src/session-manager.ts
4082
+ function findActiveProcessBySessionId(sessionID) {
4083
+ let found;
4084
+ for (const ap of activeProcesses.values()) {
4085
+ if (ap.opencodeSessionID === sessionID) found = ap;
4099
4086
  }
4100
- const sid = claudeSessions.get(sessionKey2);
4101
- if (!sid) return cliArgs;
4102
- return [...cliArgs, "--resume", sid];
4087
+ return found;
4103
4088
  }
4104
- function respawnActiveProcess(sessionKey2, cliPath, cliArgs, cwd, ignoreAnthropicApiKey) {
4105
- const old = activeProcesses.get(sessionKey2);
4106
- if (!old) return void 0;
4107
- const turnWasInFlight = isTurnInFlight(old);
4108
- activeProcesses.delete(sessionKey2);
4109
- old.proc.removeAllListeners("exit");
4110
- try {
4111
- old.proc.kill();
4112
- } catch {
4089
+ var UNATTENDED_LINE_CAP = 500;
4090
+ var UNATTENDED_BYTE_CAP = 2 * 1024 * 1024;
4091
+ function bufferUnattendedLine(ap, line) {
4092
+ const lines = ap.unattendedLines ??= [];
4093
+ lines.push(line);
4094
+ let bytes = 0;
4095
+ for (const kept of lines) bytes += Buffer.byteLength(kept);
4096
+ while (lines.length > 0 && (lines.length > UNATTENDED_LINE_CAP || bytes > UNATTENDED_BYTE_CAP)) {
4097
+ bytes -= Buffer.byteLength(lines.shift());
4098
+ ap.unattendedDropped = (ap.unattendedDropped ?? 0) + 1;
4113
4099
  }
4114
- const replacement = spawnClaudeProcess(
4115
- cliPath,
4116
- appendResumeIfNeeded(sessionKey2, old.cliArgs ?? cliArgs),
4117
- cwd,
4118
- sessionKey2,
4119
- old.proxyServer,
4120
- old.mcpHash,
4121
- old.systemPromptFile,
4122
- ignoreAnthropicApiKey,
4123
- old.effort,
4124
- old.promptCacheTtl
4125
- );
4126
- replacement.pendingProxyCompletions = old.pendingProxyCompletions;
4127
- delete old.pendingProxyCompletions;
4128
- if (turnWasInFlight) noteTurnStarted(replacement);
4129
- return replacement;
4130
4100
  }
4131
- function buildCliArgs(opts) {
4132
- const {
4133
- sessionKey: sessionKey2,
4134
- skipPermissions,
4135
- includeSessionId = true,
4136
- model,
4137
- permissionMode,
4138
- mcpConfig,
4139
- strictMcpConfig,
4140
- disallowedTools,
4141
- appendSystemPromptFile,
4142
- pluginDirs,
4143
- thinking,
4144
- thinkingDisplay,
4145
- fastMode,
4146
- cliVersion
4147
- } = opts;
4148
- const args = [
4149
- "--print",
4150
- "--output-format",
4151
- "stream-json",
4152
- "--input-format",
4153
- "stream-json",
4154
- "--include-partial-messages",
4155
- "--verbose"
4156
- ];
4157
- if (model) {
4158
- args.push("--model", model);
4159
- }
4160
- const readOnly = isReadOnlyPermissionMode(permissionMode);
4161
- if (readOnly) {
4162
- if (cliSupportsRestricted(cliVersion ?? null)) {
4163
- args.push("--restricted");
4164
- } else {
4165
- log.warn(
4166
- 'permissionPreset "read-only": this claude CLI has no --restricted (2.1.258+), so file reads are not confined to the working directory. The mutating tools are still refused via --disallowedTools and every permission request is denied.',
4167
- { cliVersion: cliVersion?.raw ?? "unknown" }
4168
- );
4169
- }
4170
- if (cliSupportsPermissionPrompts(cliVersion ?? null)) {
4171
- args.push("--permission-prompts", "none");
4172
- } else {
4173
- log.warn(
4174
- `permissionPreset "read-only": this claude CLI has no --permission-prompts (2.1.263+), so permission requests are denied by the plugin's own can_use_tool handler instead of by the CLI. Same outcome, one layer fewer.`,
4175
- { cliVersion: cliVersion?.raw ?? "unknown" }
4176
- );
4177
- }
4178
- } else if (permissionMode) {
4179
- args.push("--permission-mode", permissionMode);
4180
- }
4181
- if (includeSessionId) {
4182
- const sessionId = claudeSessions.get(sessionKey2);
4183
- if (sessionId && !activeProcesses.has(sessionKey2)) {
4184
- args.push("--resume", sessionId);
4185
- }
4186
- }
4187
- if (mcpConfig) {
4188
- const configs = Array.isArray(mcpConfig) ? mcpConfig : [mcpConfig];
4189
- const filtered = configs.filter((c) => typeof c === "string" && c.length > 0);
4190
- if (filtered.length > 0) {
4191
- args.push("--mcp-config", ...filtered);
4192
- }
4193
- }
4194
- if (strictMcpConfig) {
4195
- args.push("--strict-mcp-config");
4196
- }
4197
- if (disallowedTools && disallowedTools.length > 0) {
4198
- args.push("--disallowedTools", ...disallowedTools);
4199
- }
4200
- if (thinking && cliSupportsThinking(cliVersion ?? null)) {
4201
- args.push("--thinking", thinking);
4202
- }
4203
- if (thinkingDisplay && cliSupportsThinkingDisplay(cliVersion ?? null)) {
4204
- args.push("--thinking-display", thinkingDisplay);
4205
- }
4206
- if (appendSystemPromptFile) {
4207
- args.push("--append-system-prompt-file", appendSystemPromptFile);
4208
- }
4209
- for (const dir of pluginDirs ?? []) {
4210
- args.push("--plugin-dir", dir);
4211
- }
4212
- if (fastMode && cliSupportsFastMode(cliVersion ?? null)) {
4213
- args.push("--settings", JSON.stringify({ fastMode: true }));
4214
- }
4215
- if (skipPermissions && permissionMode !== "plan" && !readOnly) {
4216
- args.push("--dangerously-skip-permissions");
4217
- }
4218
- return args;
4219
- }
4220
- function sessionKey(cwd, modelId) {
4221
- return `${cwd}::${modelId}`;
4222
- }
4223
- function describeSessionKey(key) {
4224
- const parts = key.split("::");
4225
- return {
4226
- cwd: parts[0] ?? "unknown",
4227
- model: parts[1] ?? "unknown",
4228
- session: parts[3] ?? "unknown",
4229
- compaction: parts[2] === "compaction"
4230
- };
4101
+ function takeUnattendedLines(ap) {
4102
+ const lines = ap.unattendedLines ?? [];
4103
+ const dropped = ap.unattendedDropped ?? 0;
4104
+ ap.unattendedLines = [];
4105
+ ap.unattendedDropped = 0;
4106
+ return { lines, dropped };
4231
4107
  }
4232
- function snapshotActiveProcesses(now = Date.now()) {
4233
- const out = [];
4234
- for (const [key, ap] of activeProcesses) {
4235
- const described = describeSessionKey(key);
4236
- const lastStderr = ap.lastStderr;
4237
- out.push({
4238
- sessionKey: key,
4239
- session: ap.opencodeSessionID ?? described.session,
4240
- model: described.model,
4241
- compaction: described.compaction,
4242
- pid: ap.proc.pid,
4243
- inFlight: ap.turnInFlight === true,
4244
- ageMs: ap.startedAt === void 0 ? void 0 : Math.max(0, now - ap.startedAt),
4245
- effort: ap.effort,
4246
- attached: ap.lineEmitter.listenerCount("line") > 0,
4247
- proxyUrl: ap.proxyServer?.url,
4248
- lastStderr: typeof lastStderr === "string" ? lastStderr : void 0
4249
- });
4250
- }
4251
- return out;
4108
+ var STDERR_RETAIN_BYTES = 2 * 1024;
4109
+ function retainStderr(ap, chunk) {
4110
+ ap.lastStderr = ((ap.lastStderr ?? "") + chunk).slice(-STDERR_RETAIN_BYTES);
4252
4111
  }
4112
+ function describeChildCrash(exitCode, signal, lastStderr) {
4113
+ const how = signal ? `was killed by ${signal}` : typeof exitCode === "number" ? `exited with code ${exitCode}` : "closed its output";
4114
+ const tail = lastStderr?.trim();
4115
+ return `The Claude Code CLI ${how} before finishing this turn (no result was emitted), so the answer above may be incomplete.` + (tail ? `
4253
4116
 
4254
- // src/btw-command.ts
4255
- var BtwHandledError = class extends Error {
4256
- name = "BtwHandledError";
4257
- constructor(message = "/btw was handled by the claude-code plugin; nothing to add to this conversation.") {
4258
- super(message);
4259
- }
4260
- };
4261
- var BTW_COMMAND_DESCRIPTION = "Ask a side question in the live Claude Code session without changing its context";
4262
- var BTW_NO_SESSION_MESSAGE = "/btw needs a live Claude Code session in this conversation. Send a normal message with a Claude Code model first, then ask again.";
4263
- var BTW_INLINE_HANDLED_MESSAGE = "/btw was answered inside the running turn; nothing to add to this conversation.";
4264
- var BTW_TURN_TOO_LONG_MESSAGE = "/btw gave up waiting for this turn to end. Ask again once it is over.";
4265
- var IDLE_POLL_MS = 500;
4266
- var IDLE_WAIT_MAX_MS = 30 * 6e4;
4267
- var BUSY_SETTLE_MS = 1500;
4268
- var SPAWN_WAIT_MAX_MS = 3e4;
4269
- var INLINE_POLL_MS = 200;
4270
- var INLINE_WAIT_MAX_MS = 2e4;
4271
- var PENDING_ANSWER_TTL_MS = 10 * 6e4;
4272
- var PENDING_ANSWER_CAP = 32;
4273
- var pendingAnswers = /* @__PURE__ */ new Map();
4274
- function rememberSideQuestionAnswer(sessionID, question, answer, now = Date.now()) {
4275
- for (const [id, entry] of pendingAnswers) {
4276
- if (now - entry.at > PENDING_ANSWER_TTL_MS) pendingAnswers.delete(id);
4277
- }
4278
- pendingAnswers.delete(sessionID);
4279
- while (pendingAnswers.size >= PENDING_ANSWER_CAP) {
4280
- const oldest = pendingAnswers.keys().next().value;
4281
- if (oldest === void 0) break;
4282
- pendingAnswers.delete(oldest);
4283
- }
4284
- pendingAnswers.set(sessionID, { question: question.trim(), answer, at: now });
4285
- }
4286
- function takeSideQuestionAnswer(sessionID, question, now = Date.now()) {
4287
- const entry = pendingAnswers.get(sessionID);
4288
- if (!entry) return void 0;
4289
- pendingAnswers.delete(sessionID);
4290
- if (!question.trim().startsWith(entry.question) || now - entry.at > PENDING_ANSWER_TTL_MS) return void 0;
4291
- return entry.answer;
4292
- }
4293
- var INLINE_ASIDE_MARKER = "\u258C **btw:**";
4294
- var LEGACY_INLINE_ASIDE_MARKERS = ["> **btw:**"];
4295
- function barEveryLine(text) {
4296
- return text.split("\n").map((line) => line.trim() === "" ? "\u258C" : `\u258C ${line}`).join("\n");
4117
+ Last stderr from the CLI:
4118
+ ${tail}` : "");
4297
4119
  }
4298
- function oneLine(question) {
4299
- return question.replace(/\s+/g, " ").trim();
4120
+ var activeProcesses = /* @__PURE__ */ new Map();
4121
+ var claudeSessions = /* @__PURE__ */ new Map();
4122
+ var idleEvictionTimers = /* @__PURE__ */ new Map();
4123
+ var MAX_IDLE_TIMEOUT_MS = 2147483647;
4124
+ var DEFAULT_IDLE_PROCESS_TIMEOUT_MS = 0;
4125
+ function resolveIdleProcessTimeoutMs(configured) {
4126
+ return configured === void 0 ? DEFAULT_IDLE_PROCESS_TIMEOUT_MS : configured;
4300
4127
  }
4301
- function asideHeader(question) {
4302
- return `${INLINE_ASIDE_MARKER} ${oneLine(question)}`;
4128
+ var MAX_ACTIVE_PROCESSES = 16;
4129
+ var PROCESS_EXIT_TIMEOUT_MS = 1500;
4130
+ var PROCESS_FORCE_EXIT_TIMEOUT_MS = 500;
4131
+ var CHILD_EXITED_MESSAGE = "Claude CLI subprocess closed before pending tool calls were resolved";
4132
+ function envFlagEnabled(value) {
4133
+ if (value === void 0) return false;
4134
+ const normalized = value.trim().toLowerCase();
4135
+ if (!normalized) return false;
4136
+ return !["0", "false", "no", "off"].includes(normalized);
4303
4137
  }
4304
- function formatInlineAside(question, answer) {
4305
- return `
4306
- ${asideHeader(question)}
4307
- \u258C
4308
- ${barEveryLine(answer.trim())}
4309
- `;
4138
+ function isClaudeThinkingDisabled() {
4139
+ return envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_THINKING) || envFlagEnabled(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING);
4310
4140
  }
4311
- var INLINE_ASIDE_SENT_NOTE = "*sent to Claude on the side*";
4312
- function formatInlineAsideAsk(question) {
4313
- return `
4314
- ${asideHeader(question)}
4315
- \u258C ${INLINE_ASIDE_SENT_NOTE}
4316
- `;
4141
+ function cliEffortLevel(effort) {
4142
+ return effort === "minimal" ? "low" : effort;
4317
4143
  }
4318
- var asideSinks = /* @__PURE__ */ new Map();
4319
- function registerAsideSink(sessionID, sink) {
4320
- asideSinks.set(sessionID, sink);
4321
- return () => {
4322
- if (asideSinks.get(sessionID) === sink) asideSinks.delete(sessionID);
4144
+ function claudeSpawnEnv(opts) {
4145
+ const env = {
4146
+ ...process.env,
4147
+ TERM: "xterm-256color",
4148
+ // Pin the child to the binary whose version we detected, and keep it off
4149
+ // non-essential network calls. Fills gaps only, so an explicit shell value
4150
+ // survives: see `cliHygieneEnv` for why the version has to hold still.
4151
+ ...cliHygieneEnv()
4323
4152
  };
4324
- }
4325
- function emitAsideInline(sessionID, text) {
4326
- const sink = asideSinks.get(sessionID);
4327
- if (!sink) return false;
4328
- try {
4329
- return sink(text);
4330
- } catch (error) {
4331
- log.debug("btw: could not write the aside into the running turn", { sessionID, error: errorText(error) });
4332
- return false;
4153
+ if (opts?.effort) {
4154
+ env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort);
4333
4155
  }
4334
- }
4335
- function showToast(client, body) {
4336
- try {
4337
- void client?.tui?.showToast?.({ body })?.catch((error) => {
4338
- log.debug("btw toast failed", { error: errorText(error) });
4339
- });
4340
- } catch (error) {
4341
- log.debug("btw toast failed", { error: errorText(error) });
4156
+ if (opts?.promptCacheTtl) {
4157
+ env.CLAUDE_CODE_PROMPT_CACHE_TTL = opts.promptCacheTtl;
4342
4158
  }
4343
- }
4344
- function isProcessBusy(active) {
4345
- return active.lineEmitter.listenerCount("line") > 0;
4346
- }
4347
- async function sessionStatus(client, sessionID) {
4348
- const status = client?.session?.status;
4349
- if (!status) return "unknown";
4350
- try {
4351
- const result = await status.call(client.session);
4352
- const entry = result.data?.[sessionID];
4353
- return entry && entry.type !== "idle" ? "busy" : "idle";
4354
- } catch (error) {
4355
- log.debug("btw: could not read session status", { sessionID, error: errorText(error) });
4356
- return "unknown";
4159
+ if (opts?.ignoreAnthropicApiKey) {
4160
+ delete env.ANTHROPIC_API_KEY;
4161
+ delete env.ANTHROPIC_AUTH_TOKEN;
4162
+ }
4163
+ if (!isClaudeThinkingDisabled() && process.env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES === void 0) {
4164
+ env.CLAUDE_CODE_SHOW_THINKING_SUMMARIES = "1";
4357
4165
  }
4166
+ return env;
4358
4167
  }
4359
- async function waitForSessionIdle(client, sessionID, options = {}) {
4360
- const pollMs = options.pollMs ?? IDLE_POLL_MS;
4361
- const timeoutMs = options.timeoutMs ?? IDLE_WAIT_MAX_MS;
4362
- const started = Date.now();
4363
- for (; ; ) {
4364
- if (options.stop?.()) return true;
4365
- if (await sessionStatus(client, sessionID) !== "busy") return true;
4366
- if (Date.now() - started >= timeoutMs) return false;
4367
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4168
+ function touch(key) {
4169
+ const existing = activeProcesses.get(key);
4170
+ if (existing) {
4171
+ activeProcesses.delete(key);
4172
+ activeProcesses.set(key, existing);
4368
4173
  }
4369
4174
  }
4370
- async function deliverAsideInline(client, sessionID, text, options = {}) {
4371
- const pollMs = options.inlinePollMs ?? INLINE_POLL_MS;
4372
- const timeoutMs = options.inlineWaitMs ?? INLINE_WAIT_MAX_MS;
4373
- const started = Date.now();
4374
- for (; ; ) {
4375
- if (emitAsideInline(sessionID, text)) return true;
4376
- if (Date.now() - started >= timeoutMs) return false;
4377
- if (await sessionStatus(client, sessionID) !== "busy") return false;
4378
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4379
- }
4380
- }
4381
- async function waitForAsideProcess(client, sessionID, options = {}) {
4382
- const pollMs = options.pollMs ?? IDLE_POLL_MS;
4383
- const settleMs = options.settleMs ?? BUSY_SETTLE_MS;
4384
- const spawnWaitMs = options.spawnWaitMs ?? SPAWN_WAIT_MAX_MS;
4385
- const started = Date.now();
4386
- for (; ; ) {
4387
- const active = findActiveProcessBySessionId(sessionID);
4388
- if (active) return active;
4389
- const busy = await sessionStatus(client, sessionID) === "busy";
4390
- const waitedMs = Date.now() - started;
4391
- if (!busy && waitedMs >= settleMs) {
4392
- log.info("btw: no live claude process for session, leaving it to the turn", { sessionID, waitedMs });
4393
- return void 0;
4175
+ function evictIfNeeded() {
4176
+ while (activeProcesses.size >= MAX_ACTIVE_PROCESSES) {
4177
+ let victimKey;
4178
+ for (const [key, ap] of activeProcesses) {
4179
+ if (!isTurnInFlight(ap)) {
4180
+ victimKey = key;
4181
+ break;
4182
+ }
4394
4183
  }
4395
- if (busy && waitedMs >= spawnWaitMs) {
4396
- log.warn("btw: a turn is running but no claude process appeared for it", { sessionID, waitedMs });
4397
- return void 0;
4184
+ if (!victimKey) {
4185
+ log.warn("every claude process is mid-turn; skipping LRU eviction", {
4186
+ active: activeProcesses.size,
4187
+ cap: MAX_ACTIVE_PROCESSES
4188
+ });
4189
+ return;
4398
4190
  }
4399
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4191
+ log.info("evicting LRU claude process", { sessionKey: victimKey });
4192
+ deleteActiveProcess(victimKey);
4400
4193
  }
4401
4194
  }
4402
- async function settleSessionBusy(client, sessionID, active, options = {}) {
4403
- const pollMs = options.pollMs ?? IDLE_POLL_MS;
4404
- const settleMs = options.settleMs ?? BUSY_SETTLE_MS;
4405
- const started = Date.now();
4406
- for (; ; ) {
4407
- const status = await sessionStatus(client, sessionID);
4408
- if (status === "busy") return true;
4409
- if (status === "unknown") return isProcessBusy(active);
4410
- if (Date.now() - started >= settleMs) return false;
4411
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4195
+ var TURN_INTERRUPT_TIMEOUT_MS = 5e3;
4196
+ function isTerminalResultLine(line) {
4197
+ if (!line.includes('"result"')) return false;
4198
+ try {
4199
+ return JSON.parse(line).type === "result";
4200
+ } catch {
4201
+ return false;
4412
4202
  }
4413
4203
  }
4414
- function errorText(error) {
4415
- if (error instanceof Error) return error.message;
4416
- if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
4417
- return error.message;
4418
- }
4419
- return String(error);
4204
+ function settleTurn(ap) {
4205
+ ap.turnInFlight = false;
4206
+ const waiters = ap.turnIdleWaiters ?? [];
4207
+ ap.turnIdleWaiters = [];
4208
+ for (const wake of waiters) wake();
4420
4209
  }
4421
- function isTextPart(part) {
4422
- return part !== null && typeof part === "object" && part.type === "text" && typeof part.text === "string";
4210
+ function noteTurnStarted(ap) {
4211
+ if (ap.asideTransport?.interactive) return;
4212
+ ap.turnInFlight = true;
4423
4213
  }
4424
- async function fetchAsideHistory(client, sessionID, question) {
4425
- const messages = client?.session?.messages;
4426
- if (!messages) return [];
4427
- try {
4428
- const result = await messages.call(client.session, { path: { id: sessionID } });
4429
- const prompt = [];
4430
- for (const message of result.data ?? []) {
4431
- const role = message.info?.role;
4432
- if (role !== "user" && role !== "assistant") continue;
4433
- prompt.push({ role, content: (message.parts ?? []).filter(isTextPart) });
4434
- }
4435
- prompt.push({ role: "user", content: `/btw ${question}` });
4436
- return collectSideQuestionHistory(prompt);
4437
- } catch (error) {
4438
- log.debug("btw: could not read aside history", { sessionID, error: errorText(error) });
4439
- return [];
4440
- }
4214
+ function noteTurnLine(ap, line) {
4215
+ if (!ap.turnInFlight) return;
4216
+ if (isTerminalResultLine(line)) settleTurn(ap);
4441
4217
  }
4442
- async function handleBtwCommand(client, input, options = {}) {
4443
- const question = input.arguments.trim();
4444
- if (!question) {
4445
- showToast(client, { title: "btw", message: SIDE_QUESTION_USAGE, variant: "warning", duration: 6e3 });
4446
- throw new BtwHandledError("/btw needs a question.");
4447
- }
4448
- const active = await waitForAsideProcess(client, input.sessionID, options);
4449
- const transport = active?.asideTransport;
4450
- if (!active || !transport) {
4451
- if (active) log.info("btw: process has no aside transport, leaving it to the turn", { sessionID: input.sessionID });
4452
- return;
4453
- }
4454
- let busy = false;
4455
- let inlineDone = false;
4456
- let markInlineDelivered = () => {
4457
- };
4458
- const inlineDelivered = new Promise((resolve6) => {
4459
- markInlineDelivered = () => {
4460
- inlineDone = true;
4461
- resolve6("inline");
4218
+ function isTurnInFlight(ap) {
4219
+ return ap.turnInFlight === true;
4220
+ }
4221
+ function awaitTurnIdle(ap, timeoutMs) {
4222
+ if (!ap.turnInFlight) return Promise.resolve(true);
4223
+ return new Promise((resolve6) => {
4224
+ const wake = () => {
4225
+ clearTimeout(timer);
4226
+ resolve6(true);
4462
4227
  };
4228
+ const timer = setTimeout(() => {
4229
+ const waiters = ap.turnIdleWaiters ?? [];
4230
+ const at = waiters.indexOf(wake);
4231
+ if (at >= 0) waiters.splice(at, 1);
4232
+ resolve6(false);
4233
+ }, timeoutMs);
4234
+ (ap.turnIdleWaiters ??= []).push(wake);
4463
4235
  });
4464
- if (isSideQuestionPending(active)) {
4465
- busy = await settleSessionBusy(client, input.sessionID, active, options);
4466
- log.info("btw: an aside is already in flight, leaving this one to the turn", { sessionID: input.sessionID, busy });
4467
- } else {
4468
- const settling = settleSessionBusy(client, input.sessionID, active, options);
4469
- const history = await fetchAsideHistory(client, input.sessionID, question);
4470
- const answer = requestSideQuestion(active, question, {
4471
- cliVersion: await detectCliVersion(transport.cliPath),
4472
- interactive: transport.interactive,
4473
- ...history.length ? { history } : {}
4474
- });
4475
- answer.catch(() => void 0);
4476
- rememberSideQuestionAnswer(input.sessionID, question, answer);
4477
- busy = await settling;
4478
- log.info("btw: aside sent ahead of its message", {
4479
- sessionID: input.sessionID,
4480
- busy,
4481
- questionLength: question.length,
4482
- history: history.length
4483
- });
4484
- const asked = busy ? deliverAsideInline(client, input.sessionID, formatInlineAsideAsk(question), options).catch(() => false) : Promise.resolve(false);
4485
- answer.then(
4486
- async (result) => {
4487
- log.info("btw: early answer arrived", { sessionID: input.sessionID, busy, responseLength: result.response.length });
4488
- if (!busy || result.synthetic) return;
4489
- await asked;
4490
- const inline = await deliverAsideInline(
4491
- client,
4492
- input.sessionID,
4493
- formatInlineAside(question, result.response),
4494
- options
4495
- );
4496
- if (inline) {
4497
- log.info("btw: answer written into the running turn", { sessionID: input.sessionID });
4498
- markInlineDelivered();
4499
- return;
4500
- }
4501
- log.info("btw: no open stream for the answer; the held message will carry it", {
4502
- sessionID: input.sessionID
4503
- });
4504
- },
4505
- (error) => {
4506
- log.warn("btw: early aside failed; the message will ask again", {
4507
- sessionID: input.sessionID,
4508
- error: errorText(error)
4509
- });
4510
- }
4511
- );
4236
+ }
4237
+ function interruptTurn(ap, timeoutMs = TURN_INTERRUPT_TIMEOUT_MS) {
4238
+ if (!ap.turnInFlight) return Promise.resolve(true);
4239
+ const stdin = ap.proc.stdin;
4240
+ if (ap.asideTransport?.interactive || !stdin || !stdin.writable) {
4241
+ log.notice("cannot interrupt this transport; waiting for the turn to end");
4242
+ return awaitTurnIdle(ap, timeoutMs);
4512
4243
  }
4513
- if (!busy) return;
4514
- const started = Date.now();
4515
- const outcome = await Promise.race([
4516
- inlineDelivered,
4517
- waitForSessionIdle(client, input.sessionID, { ...options, stop: () => inlineDone }).then(
4518
- (idle) => idle ? "idle" : "timeout"
4519
- )
4520
- ]);
4521
- if (outcome === "inline") {
4522
- log.info("btw: answered inside the running turn, dropping the /btw message", {
4523
- sessionID: input.sessionID,
4524
- waitedMs: Date.now() - started
4244
+ try {
4245
+ stdin.write(
4246
+ JSON.stringify({
4247
+ type: "control_request",
4248
+ request_id: randomUUID3(),
4249
+ request: { subtype: "interrupt" }
4250
+ }) + "\n"
4251
+ );
4252
+ } catch (error) {
4253
+ log.warn("failed to write interrupt control request", {
4254
+ error: error instanceof Error ? error.message : String(error)
4525
4255
  });
4526
- throw new BtwHandledError(BTW_INLINE_HANDLED_MESSAGE);
4527
- }
4528
- log.info("btw: turn over, releasing the /btw message", {
4529
- sessionID: input.sessionID,
4530
- idle: outcome === "idle",
4531
- waitedMs: Date.now() - started
4532
- });
4533
- if (outcome === "timeout") {
4534
- showToast(client, { title: "btw", message: BTW_TURN_TOO_LONG_MESSAGE, variant: "warning", duration: 8e3 });
4535
- throw new BtwHandledError(BTW_TURN_TOO_LONG_MESSAGE);
4256
+ return Promise.resolve(false);
4536
4257
  }
4258
+ return awaitTurnIdle(ap, timeoutMs);
4537
4259
  }
4538
-
4539
- // src/plan-usage.ts
4540
- import { execFile as execFile2 } from "child_process";
4541
- var PLAN_USAGE_ARGUMENTS = /* @__PURE__ */ new Set(["usage", "cost", "stats", "limits"]);
4542
- function wantsPlanUsage(argument) {
4543
- return PLAN_USAGE_ARGUMENTS.has(argument.trim().toLowerCase());
4260
+ function cancelIdleProcessEviction(key) {
4261
+ const timer = idleEvictionTimers.get(key);
4262
+ if (!timer) return;
4263
+ clearTimeout(timer);
4264
+ idleEvictionTimers.delete(key);
4544
4265
  }
4545
- var PLAN_USAGE_MAX_CHARS = 4e3;
4546
- function truncate(text) {
4547
- return text.length <= PLAN_USAGE_MAX_CHARS ? text : `${text.slice(0, PLAN_USAGE_MAX_CHARS)}
4548
- [truncated]`;
4266
+ function getActiveProcess(key) {
4267
+ const ap = activeProcesses.get(key);
4268
+ if (ap) {
4269
+ cancelIdleProcessEviction(key);
4270
+ touch(key);
4271
+ }
4272
+ return ap;
4549
4273
  }
4550
- function parsePlanUsage(stdout) {
4551
- const trimmed = stdout.trim();
4552
- if (!trimmed) return { status: "failed", error: "the CLI printed nothing" };
4553
- const candidates = trimmed.split("\n").reverse();
4554
- for (const line of candidates) {
4555
- const start = line.indexOf("{");
4556
- if (start === -1) continue;
4557
- let parsed;
4558
- try {
4559
- parsed = JSON.parse(line.slice(start));
4560
- } catch {
4561
- continue;
4562
- }
4563
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
4564
- const record = parsed;
4565
- if (record.type !== void 0 && record.type !== "result") continue;
4566
- const text = typeof record.result === "string" ? record.result.trim() : "";
4567
- if (record.is_error === true) {
4568
- return { status: "failed", error: text || "the CLI reported an error" };
4274
+ function setActiveProcess(key, ap) {
4275
+ cancelIdleProcessEviction(key);
4276
+ activeProcesses.set(key, ap);
4277
+ }
4278
+ function scheduleIdleProcessEviction(key, timeoutMs) {
4279
+ cancelIdleProcessEviction(key);
4280
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_IDLE_TIMEOUT_MS) {
4281
+ return;
4282
+ }
4283
+ const scheduledProcess = activeProcesses.get(key);
4284
+ if (!scheduledProcess) return;
4285
+ const timer = setTimeout(() => {
4286
+ idleEvictionTimers.delete(key);
4287
+ if (activeProcesses.get(key) !== scheduledProcess) return;
4288
+ if (isTurnInFlight(scheduledProcess)) {
4289
+ log.info("idle timer found a turn in flight; re-arming", { sessionKey: key, timeoutMs });
4290
+ scheduleIdleProcessEviction(key, timeoutMs);
4291
+ return;
4569
4292
  }
4570
- if (!text) continue;
4571
- return {
4572
- status: "ok",
4573
- text: truncate(text),
4574
- costUsd: typeof record.total_cost_usd === "number" ? record.total_cost_usd : 0,
4575
- numTurns: typeof record.num_turns === "number" ? record.num_turns : 0
4293
+ log.info("evicting idle claude process", { sessionKey: key, timeoutMs });
4294
+ deleteActiveProcess(key);
4295
+ }, timeoutMs);
4296
+ timer.unref();
4297
+ idleEvictionTimers.set(key, timer);
4298
+ }
4299
+ function detachActiveProcess(key) {
4300
+ cancelIdleProcessEviction(key);
4301
+ const ap = activeProcesses.get(key);
4302
+ if (!ap) return void 0;
4303
+ activeProcesses.delete(key);
4304
+ if (ap.proxyServer) {
4305
+ void ap.proxyServer.close();
4306
+ rejectAllPendingProxyCallsForSession(key, new Error(SERVER_CLOSED_MESSAGE));
4307
+ }
4308
+ return ap;
4309
+ }
4310
+ function deleteActiveProcessesForSession(sessionID) {
4311
+ if (!sessionID || sessionID === "default") return [];
4312
+ const released = [];
4313
+ for (const [key, ap] of [...activeProcesses]) {
4314
+ const owned = ap.opencodeSessionID === sessionID || describeSessionKey(key).session === sessionID;
4315
+ if (!owned) continue;
4316
+ log.info("releasing claude process for deleted session", { sessionKey: key, sessionID });
4317
+ void deleteActiveProcessAndWait(key);
4318
+ released.push(key);
4319
+ }
4320
+ for (const key of [...claudeSessions.keys()]) {
4321
+ if (describeSessionKey(key).session !== sessionID) continue;
4322
+ deleteClaudeSessionId(key);
4323
+ clearCompression(key);
4324
+ if (!released.includes(key)) released.push(key);
4325
+ }
4326
+ for (const key of released) clearBackgroundTasks(key);
4327
+ return released;
4328
+ }
4329
+ function killAllActiveProcesses() {
4330
+ const keys = [...activeProcesses.keys()];
4331
+ for (const key of keys) deleteActiveProcess(key);
4332
+ for (const key of keys) clearBackgroundTasks(key);
4333
+ return keys;
4334
+ }
4335
+ var processExitCleanupWired = false;
4336
+ function ensureProcessExitCleanup() {
4337
+ if (processExitCleanupWired) return false;
4338
+ processExitCleanupWired = true;
4339
+ process.once("exit", () => {
4340
+ killAllActiveProcesses();
4341
+ });
4342
+ return true;
4343
+ }
4344
+ function deleteActiveProcess(key) {
4345
+ const ap = detachActiveProcess(key);
4346
+ ap?.proc.kill();
4347
+ }
4348
+ function hasProcessExited(proc) {
4349
+ return proc.exitCode !== null || proc.signalCode !== null;
4350
+ }
4351
+ function waitForProcessExit(proc, timeoutMs) {
4352
+ if (hasProcessExited(proc)) return Promise.resolve(true);
4353
+ return new Promise((resolve6) => {
4354
+ const onExit = () => {
4355
+ clearTimeout(timer);
4356
+ resolve6(true);
4576
4357
  };
4358
+ const timer = setTimeout(() => {
4359
+ proc.off("exit", onExit);
4360
+ resolve6(hasProcessExited(proc));
4361
+ }, timeoutMs);
4362
+ proc.once("exit", onExit);
4363
+ });
4364
+ }
4365
+ async function deleteActiveProcessAndWait(key, options = {}) {
4366
+ const ap = detachActiveProcess(key);
4367
+ if (!ap || hasProcessExited(ap.proc)) return true;
4368
+ const gracefulExit = waitForProcessExit(
4369
+ ap.proc,
4370
+ options.exitTimeoutMs ?? PROCESS_EXIT_TIMEOUT_MS
4371
+ );
4372
+ ap.proc.kill();
4373
+ if (await gracefulExit) return true;
4374
+ const forcedExit = waitForProcessExit(
4375
+ ap.proc,
4376
+ options.forceExitTimeoutMs ?? PROCESS_FORCE_EXIT_TIMEOUT_MS
4377
+ );
4378
+ ap.proc.kill("SIGKILL");
4379
+ if (await forcedExit) return true;
4380
+ log.warn("claude process did not exit; starting a fresh session", {
4381
+ sessionKey: key
4382
+ });
4383
+ deleteClaudeSessionId(key);
4384
+ return false;
4385
+ }
4386
+ function getClaudeSessionId(key) {
4387
+ return claudeSessions.get(key);
4388
+ }
4389
+ var MAX_CLAUDE_SESSION_ENTRIES = 64;
4390
+ function claudeSessionIsBusy(key) {
4391
+ if (activeProcesses.has(key)) return true;
4392
+ return getPendingProxyCalls(key).length > 0 || hasExitPlanModeQuestions(key);
4393
+ }
4394
+ function capClaudeSessions() {
4395
+ for (const key of [...claudeSessions.keys()]) {
4396
+ if (claudeSessions.size <= MAX_CLAUDE_SESSION_ENTRIES) return;
4397
+ if (claudeSessionIsBusy(key)) continue;
4398
+ log.info("claude session cap reached; releasing an idle session", {
4399
+ sessionKey: key,
4400
+ size: claudeSessions.size,
4401
+ cap: MAX_CLAUDE_SESSION_ENTRIES
4402
+ });
4403
+ deleteClaudeSessionId(key);
4577
4404
  }
4578
- return { status: "failed", error: "no result object in the CLI's reply" };
4579
4405
  }
4580
- function runCli(cliPath, args, timeoutMs, env) {
4581
- return new Promise((resolve6, reject) => {
4582
- execFile2(
4583
- cliPath,
4584
- args,
4585
- // `killSignal` so a CLI wedged on a hook is actually gone, and a generous
4586
- // buffer because the reply is prose of unbounded length.
4587
- { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024, env },
4588
- (error, stdout) => {
4589
- if (stdout && stdout.trim()) return resolve6(stdout);
4590
- if (error) return reject(error);
4591
- resolve6(stdout ?? "");
4406
+ function setClaudeSessionId(key, sessionId) {
4407
+ claudeSessions.delete(key);
4408
+ claudeSessions.set(key, sessionId);
4409
+ capClaudeSessions();
4410
+ }
4411
+ function deleteClaudeSessionId(key) {
4412
+ clearExitPlanModeQuestions(key);
4413
+ clearAccountFailoverQuestions(key);
4414
+ const claudeSessionId = claudeSessions.get(key);
4415
+ if (claudeSessionId) clearLedger(claudeSessionId);
4416
+ claudeSessions.delete(key);
4417
+ }
4418
+ function effortSessionKey(baseKey, effort) {
4419
+ return effort ? `${baseKey}::effort=${effort}` : baseKey;
4420
+ }
4421
+ function invalidateOtherEffortSessions(baseKey, effort) {
4422
+ const levels = [
4423
+ void 0,
4424
+ "minimal",
4425
+ "low",
4426
+ "medium",
4427
+ "high",
4428
+ "xhigh",
4429
+ "max"
4430
+ ];
4431
+ const staleKeys = levels.filter((level) => level !== effort).map((level) => effortSessionKey(baseKey, level));
4432
+ for (const key of staleKeys) {
4433
+ const active = activeProcesses.get(key);
4434
+ if (getPendingProxyCalls(key).length || hasExitPlanModeQuestions(key) || active?.pendingProxyCompletions?.size || active && (active.lineEmitter.listenerCount("line") > 0 || isSideQuestionPending(active))) {
4435
+ throw new Error(
4436
+ "Cannot change reasoning effort while the previous effort session has pending work. Finish that work at its original effort first."
4437
+ );
4438
+ }
4439
+ }
4440
+ for (const key of staleKeys) {
4441
+ deleteActiveProcess(key);
4442
+ deleteClaudeSessionId(key);
4443
+ clearCompression(key);
4444
+ clearBackgroundTasks(key);
4445
+ }
4446
+ }
4447
+ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcpHash, systemPromptFile, ignoreAnthropicApiKey, effort, promptCacheTtl) {
4448
+ evictIfNeeded();
4449
+ log.info("spawning new claude process", {
4450
+ cliPath,
4451
+ cliArgs,
4452
+ cwd,
4453
+ sessionKey: sessionKey2,
4454
+ effort,
4455
+ promptCacheTtl
4456
+ });
4457
+ const proc = spawn(cliPath, cliArgs, {
4458
+ cwd,
4459
+ stdio: ["pipe", "pipe", "pipe"],
4460
+ env: claudeSpawnEnv({ ignoreAnthropicApiKey, effort, promptCacheTtl }),
4461
+ shell: process.platform === "win32"
4462
+ });
4463
+ const lineEmitter = new EventEmitter3();
4464
+ const ap = {
4465
+ proc,
4466
+ lineEmitter,
4467
+ proxyServer: proxyServer ?? null,
4468
+ mcpHash,
4469
+ systemPromptFile,
4470
+ effort,
4471
+ promptCacheTtl,
4472
+ startedAt: Date.now(),
4473
+ cliPath,
4474
+ cliArgs: [...cliArgs],
4475
+ unattendedLines: [],
4476
+ unattendedDropped: 0
4477
+ };
4478
+ const rl = createInterface({ input: proc.stdout });
4479
+ rl.on("line", (line) => {
4480
+ if (dispatchSideQuestionResponse(ap, line)) return;
4481
+ noteTurnLine(ap, line);
4482
+ if (lineEmitter.listenerCount("line") === 0) {
4483
+ bufferUnattendedLine(ap, line);
4484
+ return;
4485
+ }
4486
+ lineEmitter.emit("line", line);
4487
+ });
4488
+ rl.on("close", () => {
4489
+ settleTurn(ap);
4490
+ lineEmitter.emit("close");
4491
+ });
4492
+ cancelIdleProcessEviction(sessionKey2);
4493
+ activeProcesses.set(sessionKey2, ap);
4494
+ proc.on("error", (err) => {
4495
+ log.error("claude process error", { sessionKey: sessionKey2, error: err.message });
4496
+ });
4497
+ proc.stdin?.on("error", (err) => {
4498
+ log.warn("claude process stdin error", {
4499
+ sessionKey: sessionKey2,
4500
+ code: err.code,
4501
+ error: err.message
4502
+ });
4503
+ settleTurn(ap);
4504
+ });
4505
+ proc.on("exit", (code, signal) => {
4506
+ log.info("claude process exited", { code, signal, sessionKey: sessionKey2 });
4507
+ void proxyServer?.close();
4508
+ if (systemPromptFile) {
4509
+ void unlink(systemPromptFile).catch(() => {
4510
+ });
4511
+ }
4512
+ const ownsSessionKey = activeProcesses.get(sessionKey2) === ap;
4513
+ if (ownsSessionKey) {
4514
+ cancelIdleProcessEviction(sessionKey2);
4515
+ activeProcesses.delete(sessionKey2);
4516
+ if (getPendingProxyCalls(sessionKey2).length > 0) {
4517
+ rejectAllPendingProxyCallsForSession(
4518
+ sessionKey2,
4519
+ new Error(CHILD_EXITED_MESSAGE)
4520
+ );
4592
4521
  }
4593
- );
4522
+ }
4523
+ if (ownsSessionKey && code !== 0 && code !== null) {
4524
+ log.info("process exited with error, clearing session", {
4525
+ code,
4526
+ sessionKey: sessionKey2
4527
+ });
4528
+ claudeSessions.delete(sessionKey2);
4529
+ }
4530
+ });
4531
+ proc.stderr?.on("data", (data) => {
4532
+ const stderr = data.toString();
4533
+ log.debug("stderr", { data: stderr.slice(0, 200) });
4534
+ retainStderr(ap, stderr);
4535
+ reportUnrecognizedModel(stderr);
4536
+ if (stderr.includes("No conversation found") || stderr.includes("Session ID") && (stderr.includes("already in use") || stderr.includes("not found") || stderr.includes("invalid"))) {
4537
+ if (activeProcesses.get(sessionKey2) === ap) {
4538
+ log.warn("claude session ID error, clearing session", {
4539
+ sessionKey: sessionKey2,
4540
+ error: stderr.slice(0, 200)
4541
+ });
4542
+ claudeSessions.delete(sessionKey2);
4543
+ } else {
4544
+ log.debug("ignoring session ID error from stale claude process", {
4545
+ sessionKey: sessionKey2
4546
+ });
4547
+ }
4548
+ }
4594
4549
  });
4550
+ return ap;
4595
4551
  }
4596
- async function fetchPlanUsage(cliPath, options = {}) {
4597
- const timeoutMs = options.timeoutMs ?? 2e4;
4598
- const run = options.runImpl ?? runCli;
4552
+ function appendResumeIfNeeded(sessionKey2, cliArgs) {
4553
+ if (cliArgs.includes("--resume") || cliArgs.includes("--session-id")) {
4554
+ return cliArgs;
4555
+ }
4556
+ const sid = claudeSessions.get(sessionKey2);
4557
+ if (!sid) return cliArgs;
4558
+ return [...cliArgs, "--resume", sid];
4559
+ }
4560
+ function respawnActiveProcess(sessionKey2, cliPath, cliArgs, cwd, ignoreAnthropicApiKey) {
4561
+ const old = activeProcesses.get(sessionKey2);
4562
+ if (!old) return void 0;
4563
+ const turnWasInFlight = isTurnInFlight(old);
4564
+ activeProcesses.delete(sessionKey2);
4565
+ old.proc.removeAllListeners("exit");
4599
4566
  try {
4600
- const env = claudeSpawnEnv({ ignoreAnthropicApiKey: options.ignoreAnthropicApiKey });
4601
- const stdout = await run(
4602
- cliPath,
4603
- ["-p", "/cost", "--output-format", "json"],
4604
- timeoutMs,
4605
- env
4606
- );
4607
- const usage = parsePlanUsage(stdout);
4608
- if (usage.status === "ok") {
4609
- log.info("read claude plan usage", { costUsd: usage.costUsd, numTurns: usage.numTurns });
4610
- } else if (usage.status === "failed") {
4611
- log.debug("could not read claude plan usage", { error: usage.error });
4567
+ old.proc.kill();
4568
+ } catch {
4569
+ }
4570
+ const replacement = spawnClaudeProcess(
4571
+ cliPath,
4572
+ appendResumeIfNeeded(sessionKey2, old.cliArgs ?? cliArgs),
4573
+ cwd,
4574
+ sessionKey2,
4575
+ old.proxyServer,
4576
+ old.mcpHash,
4577
+ old.systemPromptFile,
4578
+ ignoreAnthropicApiKey,
4579
+ old.effort,
4580
+ old.promptCacheTtl
4581
+ );
4582
+ replacement.mcpServers = old.mcpServers;
4583
+ replacement.pendingProxyCompletions = old.pendingProxyCompletions;
4584
+ delete old.pendingProxyCompletions;
4585
+ if (turnWasInFlight) noteTurnStarted(replacement);
4586
+ return replacement;
4587
+ }
4588
+ function buildCliArgs(opts) {
4589
+ const {
4590
+ sessionKey: sessionKey2,
4591
+ skipPermissions,
4592
+ includeSessionId = true,
4593
+ model,
4594
+ permissionMode,
4595
+ mcpConfig,
4596
+ strictMcpConfig,
4597
+ disallowedTools,
4598
+ appendSystemPromptFile,
4599
+ pluginDirs,
4600
+ thinking,
4601
+ thinkingDisplay,
4602
+ fastMode,
4603
+ cliVersion
4604
+ } = opts;
4605
+ const args = [
4606
+ "--print",
4607
+ "--output-format",
4608
+ "stream-json",
4609
+ "--input-format",
4610
+ "stream-json",
4611
+ "--include-partial-messages",
4612
+ "--verbose"
4613
+ ];
4614
+ if (model) {
4615
+ args.push("--model", model);
4616
+ }
4617
+ const readOnly = isReadOnlyPermissionMode(permissionMode);
4618
+ if (readOnly) {
4619
+ if (cliSupportsRestricted(cliVersion ?? null)) {
4620
+ args.push("--restricted");
4621
+ } else {
4622
+ log.warn(
4623
+ 'permissionPreset "read-only": this claude CLI has no --restricted (2.1.258+), so file reads are not confined to the working directory. The mutating tools are still refused via --disallowedTools and every permission request is denied.',
4624
+ { cliVersion: cliVersion?.raw ?? "unknown" }
4625
+ );
4626
+ }
4627
+ if (cliSupportsPermissionPrompts(cliVersion ?? null)) {
4628
+ args.push("--permission-prompts", "none");
4629
+ } else {
4630
+ log.warn(
4631
+ `permissionPreset "read-only": this claude CLI has no --permission-prompts (2.1.263+), so permission requests are denied by the plugin's own can_use_tool handler instead of by the CLI. Same outcome, one layer fewer.`,
4632
+ { cliVersion: cliVersion?.raw ?? "unknown" }
4633
+ );
4612
4634
  }
4613
- return usage;
4614
- } catch (error) {
4615
- const message = error instanceof Error ? error.message : String(error);
4616
- log.debug("could not read claude plan usage", { error: message });
4617
- return { status: "failed", error: message };
4618
- }
4619
- }
4620
-
4621
- // src/startup-diagnostics.ts
4622
- import { execFile as execFile3 } from "child_process";
4623
- import * as fs4 from "fs";
4624
- import * as path5 from "path";
4625
- import { promisify as promisify2 } from "util";
4626
- import { fileURLToPath } from "url";
4627
-
4628
- // src/mcp-bridge.ts
4629
- import * as fs3 from "fs";
4630
- import * as path4 from "path";
4631
- import * as os2 from "os";
4632
- import * as crypto2 from "crypto";
4633
- import {
4634
- parse as parseJsonc,
4635
- printParseErrorCode
4636
- } from "jsonc-parser";
4637
- var FILE_NAMES = ["opencode.jsonc", "opencode.json", "config.json"];
4638
- var PROJECT_FILE_NAMES = ["opencode.json", "opencode.jsonc"];
4639
- function fileExists(p) {
4640
- try {
4641
- return fs3.statSync(p).isFile();
4642
- } catch {
4643
- return false;
4635
+ } else if (permissionMode) {
4636
+ args.push("--permission-mode", permissionMode);
4644
4637
  }
4645
- }
4646
- function dirExists(p) {
4647
- try {
4648
- return fs3.statSync(p).isDirectory();
4649
- } catch {
4650
- return false;
4638
+ if (includeSessionId) {
4639
+ const sessionId = claudeSessions.get(sessionKey2);
4640
+ if (sessionId && !activeProcesses.has(sessionKey2)) {
4641
+ args.push("--resume", sessionId);
4642
+ }
4651
4643
  }
4652
- }
4653
- function readAndParse(file) {
4654
- try {
4655
- const raw = fs3.readFileSync(file, "utf8");
4656
- const errors = [];
4657
- const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
4658
- if (errors.length > 0) {
4659
- const first = errors[0];
4660
- throw new Error(
4661
- `${printParseErrorCode(first.error)} at offset ${first.offset}`
4662
- );
4644
+ if (mcpConfig) {
4645
+ const configs = Array.isArray(mcpConfig) ? mcpConfig : [mcpConfig];
4646
+ const filtered = configs.filter((c) => typeof c === "string" && c.length > 0);
4647
+ if (filtered.length > 0) {
4648
+ args.push("--mcp-config", ...filtered);
4663
4649
  }
4664
- return parsed;
4665
- } catch (e) {
4666
- log.warn("failed to parse opencode config", {
4667
- file,
4668
- error: e instanceof Error ? e.message : String(e)
4669
- });
4670
- return null;
4671
4650
  }
4651
+ if (strictMcpConfig) {
4652
+ args.push("--strict-mcp-config");
4653
+ }
4654
+ if (disallowedTools && disallowedTools.length > 0) {
4655
+ args.push("--disallowedTools", ...disallowedTools);
4656
+ }
4657
+ if (thinking && cliSupportsThinking(cliVersion ?? null)) {
4658
+ args.push("--thinking", thinking);
4659
+ }
4660
+ if (thinkingDisplay && cliSupportsThinkingDisplay(cliVersion ?? null)) {
4661
+ args.push("--thinking-display", thinkingDisplay);
4662
+ }
4663
+ if (appendSystemPromptFile) {
4664
+ args.push("--append-system-prompt-file", appendSystemPromptFile);
4665
+ }
4666
+ for (const dir of pluginDirs ?? []) {
4667
+ args.push("--plugin-dir", dir);
4668
+ }
4669
+ if (fastMode && cliSupportsFastMode(cliVersion ?? null)) {
4670
+ args.push("--settings", JSON.stringify({ fastMode: true }));
4671
+ }
4672
+ if (skipPermissions && permissionMode !== "plan" && !readOnly) {
4673
+ args.push("--dangerously-skip-permissions");
4674
+ }
4675
+ return args;
4672
4676
  }
4673
- function isPlainObject(x) {
4674
- return typeof x === "object" && x !== null && !Array.isArray(x);
4677
+ function sessionKey(cwd, modelId) {
4678
+ return `${cwd}::${modelId}`;
4675
4679
  }
4676
- function deepMerge(target, source) {
4677
- const out = { ...target };
4678
- for (const [k, v] of Object.entries(source)) {
4679
- if (v === void 0) continue;
4680
- const existing = out[k];
4681
- if (isPlainObject(existing) && isPlainObject(v)) {
4682
- out[k] = deepMerge(existing, v);
4683
- } else {
4684
- out[k] = v;
4685
- }
4686
- }
4687
- return out;
4680
+ function describeSessionKey(key) {
4681
+ const parts = key.split("::");
4682
+ return {
4683
+ cwd: parts[0] ?? "unknown",
4684
+ model: parts[1] ?? "unknown",
4685
+ session: parts[3] ?? "unknown",
4686
+ compaction: parts[2] === "compaction"
4687
+ };
4688
4688
  }
4689
- function walkUp(opts) {
4689
+ function snapshotActiveProcesses(now = Date.now()) {
4690
4690
  const out = [];
4691
- let current = path4.resolve(opts.start);
4692
- while (true) {
4693
- for (const target of opts.targets) {
4694
- const candidate = path4.join(current, target);
4695
- if (opts.predicate(candidate)) out.push(candidate);
4696
- }
4697
- if (opts.stop && current === path4.resolve(opts.stop)) break;
4698
- const parent = path4.dirname(current);
4699
- if (parent === current) break;
4700
- current = parent;
4691
+ for (const [key, ap] of activeProcesses) {
4692
+ const described = describeSessionKey(key);
4693
+ const lastStderr = ap.lastStderr;
4694
+ out.push({
4695
+ sessionKey: key,
4696
+ session: ap.opencodeSessionID ?? described.session,
4697
+ model: described.model,
4698
+ compaction: described.compaction,
4699
+ pid: ap.proc.pid,
4700
+ inFlight: ap.turnInFlight === true,
4701
+ ageMs: ap.startedAt === void 0 ? void 0 : Math.max(0, now - ap.startedAt),
4702
+ effort: ap.effort,
4703
+ attached: ap.lineEmitter.listenerCount("line") > 0,
4704
+ proxyUrl: ap.proxyServer?.url,
4705
+ lastStderr: typeof lastStderr === "string" ? lastStderr : void 0
4706
+ });
4701
4707
  }
4702
4708
  return out;
4703
4709
  }
4704
- function detectWorktree(cwd) {
4705
- const override = process.env.OPENCODE_WORKTREE;
4706
- if (override) return path4.resolve(override);
4707
- let current = path4.resolve(cwd);
4708
- while (true) {
4709
- const gitPath = path4.join(current, ".git");
4710
- try {
4711
- if (fs3.existsSync(gitPath)) return current;
4712
- } catch {
4713
- }
4714
- const parent = path4.dirname(current);
4715
- if (parent === current) return void 0;
4716
- current = parent;
4710
+
4711
+ // src/btw-command.ts
4712
+ var BtwHandledError = class extends Error {
4713
+ name = "BtwHandledError";
4714
+ constructor(message = "/btw was handled by the claude-code plugin; nothing to add to this conversation.") {
4715
+ super(message);
4716
+ }
4717
+ };
4718
+ var BTW_COMMAND_DESCRIPTION = "Ask a side question in the live Claude Code session without changing its context";
4719
+ var BTW_NO_SESSION_MESSAGE = "/btw needs a live Claude Code session in this conversation. Send a normal message with a Claude Code model first, then ask again.";
4720
+ var BTW_INLINE_HANDLED_MESSAGE = "/btw was answered inside the running turn; nothing to add to this conversation.";
4721
+ var BTW_TURN_TOO_LONG_MESSAGE = "/btw gave up waiting for this turn to end. Ask again once it is over.";
4722
+ var IDLE_POLL_MS = 500;
4723
+ var IDLE_WAIT_MAX_MS = 30 * 6e4;
4724
+ var BUSY_SETTLE_MS = 1500;
4725
+ var SPAWN_WAIT_MAX_MS = 3e4;
4726
+ var INLINE_POLL_MS = 200;
4727
+ var INLINE_WAIT_MAX_MS = 2e4;
4728
+ var PENDING_ANSWER_TTL_MS = 10 * 6e4;
4729
+ var PENDING_ANSWER_CAP = 32;
4730
+ var pendingAnswers = /* @__PURE__ */ new Map();
4731
+ function rememberSideQuestionAnswer(sessionID, question, answer, now = Date.now()) {
4732
+ for (const [id, entry] of pendingAnswers) {
4733
+ if (now - entry.at > PENDING_ANSWER_TTL_MS) pendingAnswers.delete(id);
4734
+ }
4735
+ pendingAnswers.delete(sessionID);
4736
+ while (pendingAnswers.size >= PENDING_ANSWER_CAP) {
4737
+ const oldest = pendingAnswers.keys().next().value;
4738
+ if (oldest === void 0) break;
4739
+ pendingAnswers.delete(oldest);
4717
4740
  }
4741
+ pendingAnswers.set(sessionID, { question: question.trim(), answer, at: now });
4718
4742
  }
4719
- function globalConfigDir() {
4720
- const xdg = process.env.XDG_CONFIG_HOME ?? path4.join(os2.homedir(), ".config");
4721
- return path4.join(xdg, "opencode");
4743
+ function takeSideQuestionAnswer(sessionID, question, now = Date.now()) {
4744
+ const entry = pendingAnswers.get(sessionID);
4745
+ if (!entry) return void 0;
4746
+ pendingAnswers.delete(sessionID);
4747
+ if (!question.trim().startsWith(entry.question) || now - entry.at > PENDING_ANSWER_TTL_MS) return void 0;
4748
+ return entry.answer;
4722
4749
  }
4723
- function loadGlobalConfig(hostApi = "v1") {
4724
- const dir = globalConfigDir();
4725
- let merged = {};
4726
- for (const name of FILE_NAMES.slice().reverse()) {
4727
- const file = path4.join(dir, name);
4728
- if (!fileExists(file)) continue;
4729
- const parsed = readAndParse(file);
4730
- if (parsed) merged = mergeConfigLayer(merged, parsed, hostApi);
4731
- }
4732
- return merged;
4750
+ var INLINE_ASIDE_MARKER = "\u258C **btw:**";
4751
+ var LEGACY_INLINE_ASIDE_MARKERS = ["> **btw:**"];
4752
+ function barEveryLine(text) {
4753
+ return text.split("\n").map((line) => line.trim() === "" ? "\u258C" : `\u258C ${line}`).join("\n");
4733
4754
  }
4734
- function loadProjectFilesInDir(dir, hostApi = "v1") {
4735
- let merged = {};
4736
- for (const name of PROJECT_FILE_NAMES) {
4737
- const file = path4.join(dir, name);
4738
- if (!fileExists(file)) continue;
4739
- const parsed = readAndParse(file);
4740
- if (parsed) merged = mergeConfigLayer(merged, parsed, hostApi);
4755
+ function oneLine(question) {
4756
+ return question.replace(/\s+/g, " ").trim();
4757
+ }
4758
+ function asideHeader(question) {
4759
+ return `${INLINE_ASIDE_MARKER} ${oneLine(question)}`;
4760
+ }
4761
+ function formatInlineAside(question, answer) {
4762
+ return `
4763
+ ${asideHeader(question)}
4764
+ \u258C
4765
+ ${barEveryLine(answer.trim())}
4766
+ `;
4767
+ }
4768
+ var INLINE_ASIDE_SENT_NOTE = "*sent to Claude on the side*";
4769
+ function formatInlineAsideAsk(question) {
4770
+ return `
4771
+ ${asideHeader(question)}
4772
+ \u258C ${INLINE_ASIDE_SENT_NOTE}
4773
+ `;
4774
+ }
4775
+ var asideSinks = /* @__PURE__ */ new Map();
4776
+ function registerAsideSink(sessionID, sink) {
4777
+ asideSinks.set(sessionID, sink);
4778
+ return () => {
4779
+ if (asideSinks.get(sessionID) === sink) asideSinks.delete(sessionID);
4780
+ };
4781
+ }
4782
+ function emitAsideInline(sessionID, text) {
4783
+ const sink = asideSinks.get(sessionID);
4784
+ if (!sink) return false;
4785
+ try {
4786
+ return sink(text);
4787
+ } catch (error) {
4788
+ log.debug("btw: could not write the aside into the running turn", { sessionID, error: errorText(error) });
4789
+ return false;
4741
4790
  }
4742
- return merged;
4743
4791
  }
4744
- function dotOpencodeDirs(cwd, worktree) {
4745
- const dirs = [];
4746
- const seen = /* @__PURE__ */ new Set();
4747
- const push = (p) => {
4748
- const abs = path4.resolve(p);
4749
- if (!seen.has(abs) && dirExists(abs)) {
4750
- seen.add(abs);
4751
- dirs.push(abs);
4752
- }
4753
- };
4754
- for (const dir of walkUp({
4755
- start: cwd,
4756
- stop: worktree,
4757
- targets: [".opencode"],
4758
- predicate: dirExists
4759
- })) {
4760
- push(dir);
4792
+ function showToast(client, body) {
4793
+ try {
4794
+ void client?.tui?.showToast?.({ body })?.catch((error) => {
4795
+ log.debug("btw toast failed", { error: errorText(error) });
4796
+ });
4797
+ } catch (error) {
4798
+ log.debug("btw toast failed", { error: errorText(error) });
4761
4799
  }
4762
- const home = os2.homedir();
4763
- if (home) {
4764
- const homeDot = path4.join(home, ".opencode");
4765
- if (dirExists(homeDot)) push(homeDot);
4800
+ }
4801
+ function isProcessBusy(active) {
4802
+ return active.lineEmitter.listenerCount("line") > 0;
4803
+ }
4804
+ async function sessionStatus(client, sessionID) {
4805
+ const status = client?.session?.status;
4806
+ if (!status) return "unknown";
4807
+ try {
4808
+ const result = await status.call(client.session);
4809
+ const entry = result.data?.[sessionID];
4810
+ return entry && entry.type !== "idle" ? "busy" : "idle";
4811
+ } catch (error) {
4812
+ log.debug("btw: could not read session status", { sessionID, error: errorText(error) });
4813
+ return "unknown";
4766
4814
  }
4767
- const envDir = process.env.OPENCODE_CONFIG_DIR;
4768
- if (envDir && dirExists(envDir)) push(envDir);
4769
- return dirs;
4770
4815
  }
4771
- function substituteEnvPlaceholders(source) {
4772
- const out = {};
4773
- for (const [k, v] of Object.entries(source)) {
4774
- if (typeof v !== "string") continue;
4775
- out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
4776
- const resolved = process.env[name];
4777
- return typeof resolved === "string" ? resolved : "";
4778
- });
4816
+ async function waitForSessionIdle(client, sessionID, options = {}) {
4817
+ const pollMs = options.pollMs ?? IDLE_POLL_MS;
4818
+ const timeoutMs = options.timeoutMs ?? IDLE_WAIT_MAX_MS;
4819
+ const started = Date.now();
4820
+ for (; ; ) {
4821
+ if (options.stop?.()) return true;
4822
+ if (await sessionStatus(client, sessionID) !== "busy") return true;
4823
+ if (Date.now() - started >= timeoutMs) return false;
4824
+ await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4779
4825
  }
4780
- return out;
4781
4826
  }
4782
- function translateServer(name, spec) {
4783
- if (spec.enabled === false) return null;
4784
- const type = spec.type;
4785
- if (type === "local") {
4786
- const cmd = spec.command;
4787
- if (!Array.isArray(cmd) || cmd.length === 0) {
4788
- log.warn("skipping local MCP server with no command", { name });
4789
- return null;
4790
- }
4791
- const out = {
4792
- type: "stdio",
4793
- command: String(cmd[0])
4794
- };
4795
- if (cmd.length > 1) out.args = cmd.slice(1).map((s) => String(s));
4796
- if (spec.environment && typeof spec.environment === "object") {
4797
- out.env = substituteEnvPlaceholders(
4798
- spec.environment
4799
- );
4800
- }
4801
- return out;
4827
+ async function deliverAsideInline(client, sessionID, text, options = {}) {
4828
+ const pollMs = options.inlinePollMs ?? INLINE_POLL_MS;
4829
+ const timeoutMs = options.inlineWaitMs ?? INLINE_WAIT_MAX_MS;
4830
+ const started = Date.now();
4831
+ for (; ; ) {
4832
+ if (emitAsideInline(sessionID, text)) return true;
4833
+ if (Date.now() - started >= timeoutMs) return false;
4834
+ if (await sessionStatus(client, sessionID) !== "busy") return false;
4835
+ await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4802
4836
  }
4803
- if (type === "remote") {
4804
- if (typeof spec.url !== "string" || !spec.url) {
4805
- log.warn("skipping remote MCP server with no url", { name });
4806
- return null;
4837
+ }
4838
+ async function waitForAsideProcess(client, sessionID, options = {}) {
4839
+ const pollMs = options.pollMs ?? IDLE_POLL_MS;
4840
+ const settleMs = options.settleMs ?? BUSY_SETTLE_MS;
4841
+ const spawnWaitMs = options.spawnWaitMs ?? SPAWN_WAIT_MAX_MS;
4842
+ const started = Date.now();
4843
+ for (; ; ) {
4844
+ const active = findActiveProcessBySessionId(sessionID);
4845
+ if (active) return active;
4846
+ const busy = await sessionStatus(client, sessionID) === "busy";
4847
+ const waitedMs = Date.now() - started;
4848
+ if (!busy && waitedMs >= settleMs) {
4849
+ log.info("btw: no live claude process for session, leaving it to the turn", { sessionID, waitedMs });
4850
+ return void 0;
4807
4851
  }
4808
- const out = {
4809
- type: "http",
4810
- url: spec.url
4811
- };
4812
- if (spec.headers && typeof spec.headers === "object") {
4813
- out.headers = substituteEnvPlaceholders(
4814
- spec.headers
4815
- );
4852
+ if (busy && waitedMs >= spawnWaitMs) {
4853
+ log.warn("btw: a turn is running but no claude process appeared for it", { sessionID, waitedMs });
4854
+ return void 0;
4816
4855
  }
4817
- return out;
4856
+ await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4818
4857
  }
4819
- log.warn("skipping MCP server with unknown type", {
4820
- name,
4821
- type: type ?? null
4822
- });
4823
- return null;
4824
- }
4825
- function extractMcpBlock(config) {
4826
- const mcp = config.mcp;
4827
- if (!isPlainObject(mcp)) return {};
4828
- if ("servers" in mcp && !isPlainObject(mcp.servers)) return {};
4829
- const native = isPlainObject(mcp.servers) && typeof mcp.servers.type !== "string" && typeof mcp.servers.enabled !== "boolean" && typeof mcp.servers.disabled !== "boolean";
4830
- const block = native ? mcp.servers : mcp;
4831
- return Object.fromEntries(
4832
- Object.entries(block).filter(([name, spec]) => isPlainObject(spec) && (native || name !== "timeout" || typeof spec.type === "string")).map(([name, spec]) => {
4833
- const server2 = spec;
4834
- return [name, {
4835
- ...server2,
4836
- ...typeof server2.disabled === "boolean" ? { enabled: !server2.disabled } : {}
4837
- }];
4838
- })
4839
- );
4840
4858
  }
4841
- function mergeConfigLayer(target, source, hostApi) {
4842
- const merged = deepMerge(target, source);
4843
- if (hostApi === "v2" && isPlainObject(source.mcp)) {
4844
- merged.mcp = {
4845
- ...isPlainObject(merged.mcp) ? merged.mcp : {},
4846
- servers: { ...extractMcpBlock(target), ...extractMcpBlock(source) }
4847
- };
4859
+ async function settleSessionBusy(client, sessionID, active, options = {}) {
4860
+ const pollMs = options.pollMs ?? IDLE_POLL_MS;
4861
+ const settleMs = options.settleMs ?? BUSY_SETTLE_MS;
4862
+ const started = Date.now();
4863
+ for (; ; ) {
4864
+ const status = await sessionStatus(client, sessionID);
4865
+ if (status === "busy") return true;
4866
+ if (status === "unknown") return isProcessBusy(active);
4867
+ if (Date.now() - started >= settleMs) return false;
4868
+ await new Promise((resolve6) => setTimeout(resolve6, pollMs));
4848
4869
  }
4849
- return merged;
4850
4870
  }
4851
- function mergeMcp(target, source) {
4852
- const out = { ...target };
4853
- for (const [name, spec] of Object.entries(source)) {
4854
- if (!spec || typeof spec !== "object") continue;
4855
- const existing = out[name];
4856
- if (existing && typeof existing === "object") {
4857
- out[name] = deepMerge(
4858
- existing,
4859
- spec
4860
- );
4861
- } else {
4862
- out[name] = spec;
4863
- }
4871
+ function errorText(error) {
4872
+ if (error instanceof Error) return error.message;
4873
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
4874
+ return error.message;
4864
4875
  }
4865
- return out;
4876
+ return String(error);
4866
4877
  }
4867
- function bridgeOpencodeMcp(cwd, runtimeStatus, excludeServers, hostApi = "v1") {
4868
- const {
4869
- servers: merged,
4870
- enabledServerNames: allEnabledServerNames,
4871
- hash
4872
- } = mergeOpencodeMcp(cwd, runtimeStatus, hostApi);
4873
- const servers = {};
4874
- const bridgedServerNames = [];
4875
- for (const [name, spec] of Object.entries(merged)) {
4876
- if (!spec || typeof spec !== "object") continue;
4877
- if (excludeServers?.has(name)) continue;
4878
- const translated = translateServer(name, spec);
4879
- if (translated) {
4880
- servers[name] = translated;
4881
- bridgedServerNames.push(name);
4878
+ function isTextPart(part) {
4879
+ return part !== null && typeof part === "object" && part.type === "text" && typeof part.text === "string";
4880
+ }
4881
+ async function fetchAsideHistory(client, sessionID, question) {
4882
+ const messages = client?.session?.messages;
4883
+ if (!messages) return [];
4884
+ try {
4885
+ const result = await messages.call(client.session, { path: { id: sessionID } });
4886
+ const prompt = [];
4887
+ for (const message of result.data ?? []) {
4888
+ const role = message.info?.role;
4889
+ if (role !== "user" && role !== "assistant") continue;
4890
+ prompt.push({ role, content: (message.parts ?? []).filter(isTextPart) });
4882
4891
  }
4892
+ prompt.push({ role: "user", content: `/btw ${question}` });
4893
+ return collectSideQuestionHistory(prompt);
4894
+ } catch (error) {
4895
+ log.debug("btw: could not read aside history", { sessionID, error: errorText(error) });
4896
+ return [];
4883
4897
  }
4884
- return finishBridge({
4885
- servers,
4886
- bridgedServerNames,
4887
- allEnabledServerNames,
4888
- hash,
4889
- excludeServers
4890
- });
4891
4898
  }
4892
- function opencodeConfigLayers(cwd, hostApi = "v1") {
4893
- const worktree = hostApi === "v2" ? void 0 : detectWorktree(cwd);
4894
- const layers = [];
4895
- layers.push(loadGlobalConfig(hostApi));
4896
- const explicitConfig = process.env.OPENCODE_CONFIG;
4897
- if (explicitConfig && fileExists(explicitConfig)) {
4898
- const parsed = readAndParse(explicitConfig);
4899
- if (parsed) layers.push(parsed);
4899
+ async function handleBtwCommand(client, input, options = {}) {
4900
+ const question = input.arguments.trim();
4901
+ if (!question) {
4902
+ showToast(client, { title: "btw", message: SIDE_QUESTION_USAGE, variant: "warning", duration: 6e3 });
4903
+ throw new BtwHandledError("/btw needs a question.");
4900
4904
  }
4901
- const projectFiles = walkUp({
4902
- start: cwd,
4903
- stop: worktree,
4904
- targets: PROJECT_FILE_NAMES,
4905
- predicate: fileExists
4905
+ const active = await waitForAsideProcess(client, input.sessionID, options);
4906
+ const transport = active?.asideTransport;
4907
+ if (!active || !transport) {
4908
+ if (active) log.info("btw: process has no aside transport, leaving it to the turn", { sessionID: input.sessionID });
4909
+ return;
4910
+ }
4911
+ let busy = false;
4912
+ let inlineDone = false;
4913
+ let markInlineDelivered = () => {
4914
+ };
4915
+ const inlineDelivered = new Promise((resolve6) => {
4916
+ markInlineDelivered = () => {
4917
+ inlineDone = true;
4918
+ resolve6("inline");
4919
+ };
4906
4920
  });
4907
- const projectDirs = [];
4908
- const seenProjectDirs = /* @__PURE__ */ new Set();
4909
- for (const f of projectFiles) {
4910
- const d = path4.dirname(f);
4911
- if (!seenProjectDirs.has(d)) {
4912
- seenProjectDirs.add(d);
4913
- projectDirs.push(d);
4914
- }
4921
+ if (isSideQuestionPending(active)) {
4922
+ busy = await settleSessionBusy(client, input.sessionID, active, options);
4923
+ log.info("btw: an aside is already in flight, leaving this one to the turn", { sessionID: input.sessionID, busy });
4924
+ } else {
4925
+ const settling = settleSessionBusy(client, input.sessionID, active, options);
4926
+ const history = await fetchAsideHistory(client, input.sessionID, question);
4927
+ const answer = requestSideQuestion(active, question, {
4928
+ cliVersion: await detectCliVersion(transport.cliPath),
4929
+ interactive: transport.interactive,
4930
+ ...history.length ? { history } : {}
4931
+ });
4932
+ answer.catch(() => void 0);
4933
+ rememberSideQuestionAnswer(input.sessionID, question, answer);
4934
+ busy = await settling;
4935
+ log.info("btw: aside sent ahead of its message", {
4936
+ sessionID: input.sessionID,
4937
+ busy,
4938
+ questionLength: question.length,
4939
+ history: history.length
4940
+ });
4941
+ const asked = busy ? deliverAsideInline(client, input.sessionID, formatInlineAsideAsk(question), options).catch(() => false) : Promise.resolve(false);
4942
+ answer.then(
4943
+ async (result) => {
4944
+ log.info("btw: early answer arrived", { sessionID: input.sessionID, busy, responseLength: result.response.length });
4945
+ if (!busy || result.synthetic) return;
4946
+ await asked;
4947
+ const inline = await deliverAsideInline(
4948
+ client,
4949
+ input.sessionID,
4950
+ formatInlineAside(question, result.response),
4951
+ options
4952
+ );
4953
+ if (inline) {
4954
+ log.info("btw: answer written into the running turn", { sessionID: input.sessionID });
4955
+ markInlineDelivered();
4956
+ return;
4957
+ }
4958
+ log.info("btw: no open stream for the answer; the held message will carry it", {
4959
+ sessionID: input.sessionID
4960
+ });
4961
+ },
4962
+ (error) => {
4963
+ log.warn("btw: early aside failed; the message will ask again", {
4964
+ sessionID: input.sessionID,
4965
+ error: errorText(error)
4966
+ });
4967
+ }
4968
+ );
4915
4969
  }
4916
- for (const dir of projectDirs.slice().reverse()) {
4917
- layers.push(loadProjectFilesInDir(dir, hostApi));
4970
+ if (!busy) return;
4971
+ const started = Date.now();
4972
+ const outcome = await Promise.race([
4973
+ inlineDelivered,
4974
+ waitForSessionIdle(client, input.sessionID, { ...options, stop: () => inlineDone }).then(
4975
+ (idle) => idle ? "idle" : "timeout"
4976
+ )
4977
+ ]);
4978
+ if (outcome === "inline") {
4979
+ log.info("btw: answered inside the running turn, dropping the /btw message", {
4980
+ sessionID: input.sessionID,
4981
+ waitedMs: Date.now() - started
4982
+ });
4983
+ throw new BtwHandledError(BTW_INLINE_HANDLED_MESSAGE);
4918
4984
  }
4919
- if (hostApi === "v2") {
4920
- const dirs = walkUp({ start: cwd, targets: [".opencode"], predicate: dirExists }).reverse();
4921
- const extra = process.env.OPENCODE_CONFIG_DIR;
4922
- if (extra && dirExists(extra)) layers.push(loadProjectFilesInDir(extra, hostApi));
4923
- for (const dir of dirs) layers.push(loadProjectFilesInDir(dir, hostApi));
4924
- } else {
4925
- for (const dir of dotOpencodeDirs(cwd, worktree)) {
4926
- layers.push(loadProjectFilesInDir(dir));
4927
- }
4985
+ log.info("btw: turn over, releasing the /btw message", {
4986
+ sessionID: input.sessionID,
4987
+ idle: outcome === "idle",
4988
+ waitedMs: Date.now() - started
4989
+ });
4990
+ if (outcome === "timeout") {
4991
+ showToast(client, { title: "btw", message: BTW_TURN_TOO_LONG_MESSAGE, variant: "warning", duration: 8e3 });
4992
+ throw new BtwHandledError(BTW_TURN_TOO_LONG_MESSAGE);
4928
4993
  }
4929
- return layers;
4930
4994
  }
4931
- function loadMergedOpencodeConfig(cwd, hostApi = "v1") {
4932
- let merged = {};
4933
- for (const layer of opencodeConfigLayers(cwd, hostApi)) merged = mergeConfigLayer(merged, layer, hostApi);
4934
- return merged;
4995
+
4996
+ // src/plan-usage.ts
4997
+ import { execFile as execFile2 } from "child_process";
4998
+ var PLAN_USAGE_ARGUMENTS = /* @__PURE__ */ new Set(["usage", "cost", "stats", "limits"]);
4999
+ function wantsPlanUsage(argument) {
5000
+ return PLAN_USAGE_ARGUMENTS.has(argument.trim().toLowerCase());
4935
5001
  }
4936
- function mergeOpencodeMcp(cwd, runtimeStatus, hostApi = "v1") {
4937
- let merged = {};
4938
- for (const layer of opencodeConfigLayers(cwd, hostApi)) {
4939
- const servers = extractMcpBlock(layer);
4940
- merged = hostApi === "v2" ? { ...merged, ...servers } : mergeMcp(merged, servers);
4941
- }
4942
- if (runtimeStatus) {
4943
- for (const name of Object.keys(merged)) {
4944
- const status = runtimeStatus[name];
4945
- if (status === void 0) continue;
4946
- const existing = merged[name];
4947
- const base = existing && typeof existing === "object" ? existing : {};
4948
- merged[name] = { ...base, enabled: status === "connected" };
4949
- }
4950
- }
4951
- const enabledServerNames = [];
4952
- for (const [name, spec] of Object.entries(merged)) {
4953
- if (!spec || typeof spec !== "object") continue;
4954
- const enabled = spec.enabled;
4955
- if (enabled === false) continue;
4956
- enabledServerNames.push(name);
4957
- }
4958
- const mergedBody = JSON.stringify({ mcpServers: merged }, null, 2);
4959
- const hash = crypto2.createHash("sha256").update(mergedBody).digest("hex").slice(0, 12);
4960
- return { servers: merged, enabledServerNames, hash };
5002
+ var PLAN_USAGE_MAX_CHARS = 4e3;
5003
+ function truncate(text) {
5004
+ return text.length <= PLAN_USAGE_MAX_CHARS ? text : `${text.slice(0, PLAN_USAGE_MAX_CHARS)}
5005
+ [truncated]`;
4961
5006
  }
4962
- function finishBridge(input) {
4963
- const { servers, bridgedServerNames, allEnabledServerNames, hash, excludeServers } = input;
4964
- if (Object.keys(servers).length === 0) {
4965
- const allEnabledServersExcluded = excludeServers && allEnabledServerNames.length > 0 && allEnabledServerNames.every((name) => excludeServers.has(name));
4966
- if (!allEnabledServersExcluded) return null;
5007
+ function parsePlanUsage(stdout) {
5008
+ const trimmed = stdout.trim();
5009
+ if (!trimmed) return { status: "failed", error: "the CLI printed nothing" };
5010
+ const candidates = trimmed.split("\n").reverse();
5011
+ for (const line of candidates) {
5012
+ const start = line.indexOf("{");
5013
+ if (start === -1) continue;
5014
+ let parsed;
5015
+ try {
5016
+ parsed = JSON.parse(line.slice(start));
5017
+ } catch {
5018
+ continue;
5019
+ }
5020
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
5021
+ const record = parsed;
5022
+ if (record.type !== void 0 && record.type !== "result") continue;
5023
+ const text = typeof record.result === "string" ? record.result.trim() : "";
5024
+ if (record.is_error === true) {
5025
+ return { status: "failed", error: text || "the CLI reported an error" };
5026
+ }
5027
+ if (!text) continue;
4967
5028
  return {
4968
- path: "",
4969
- hash,
4970
- serverNames: [],
4971
- allEnabledServerNames
5029
+ status: "ok",
5030
+ text: truncate(text),
5031
+ costUsd: typeof record.total_cost_usd === "number" ? record.total_cost_usd : 0,
5032
+ numTurns: typeof record.num_turns === "number" ? record.num_turns : 0
4972
5033
  };
4973
5034
  }
4974
- const body = JSON.stringify({ mcpServers: servers }, null, 2);
4975
- const bodyDigest = crypto2.createHash("sha256").update(body).digest("hex").slice(0, 12);
4976
- const outPath = path4.join(
4977
- pluginTmpDir(),
4978
- `mcp-${bodyDigest}.json`
4979
- );
5035
+ return { status: "failed", error: "no result object in the CLI's reply" };
5036
+ }
5037
+ function runCli(cliPath, args, timeoutMs, env) {
5038
+ return new Promise((resolve6, reject) => {
5039
+ execFile2(
5040
+ cliPath,
5041
+ args,
5042
+ // `killSignal` so a CLI wedged on a hook is actually gone, and a generous
5043
+ // buffer because the reply is prose of unbounded length.
5044
+ { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024, env },
5045
+ (error, stdout) => {
5046
+ if (stdout && stdout.trim()) return resolve6(stdout);
5047
+ if (error) return reject(error);
5048
+ resolve6(stdout ?? "");
5049
+ }
5050
+ );
5051
+ });
5052
+ }
5053
+ async function fetchPlanUsage(cliPath, options = {}) {
5054
+ const timeoutMs = options.timeoutMs ?? 2e4;
5055
+ const run = options.runImpl ?? runCli;
4980
5056
  try {
4981
- if (!fileExists(outPath)) {
4982
- fs3.writeFileSync(outPath, body, { encoding: "utf8", mode: 384 });
5057
+ const env = claudeSpawnEnv({ ignoreAnthropicApiKey: options.ignoreAnthropicApiKey });
5058
+ const stdout = await run(
5059
+ cliPath,
5060
+ ["-p", "/cost", "--output-format", "json"],
5061
+ timeoutMs,
5062
+ env
5063
+ );
5064
+ const usage = parsePlanUsage(stdout);
5065
+ if (usage.status === "ok") {
5066
+ log.info("read claude plan usage", { costUsd: usage.costUsd, numTurns: usage.numTurns });
5067
+ } else if (usage.status === "failed") {
5068
+ log.debug("could not read claude plan usage", { error: usage.error });
4983
5069
  }
4984
- } catch (e) {
4985
- log.warn("failed to write bridged MCP config", {
4986
- error: e instanceof Error ? e.message : String(e)
4987
- });
4988
- return null;
5070
+ return usage;
5071
+ } catch (error) {
5072
+ const message = error instanceof Error ? error.message : String(error);
5073
+ log.debug("could not read claude plan usage", { error: message });
5074
+ return { status: "failed", error: message };
4989
5075
  }
4990
- log.info("bridged opencode MCP config", {
4991
- target: outPath,
4992
- hash,
4993
- servers: bridgedServerNames,
4994
- excluded: excludeServers ? Array.from(excludeServers) : []
4995
- });
4996
- return {
4997
- path: outPath,
4998
- hash,
4999
- serverNames: bridgedServerNames,
5000
- allEnabledServerNames
5001
- };
5002
5076
  }
5003
5077
 
5004
5078
  // src/startup-diagnostics.ts
5079
+ import { execFile as execFile3 } from "child_process";
5080
+ import * as fs4 from "fs";
5081
+ import * as path5 from "path";
5082
+ import { promisify as promisify2 } from "util";
5083
+ import { fileURLToPath } from "url";
5005
5084
  var cachedPluginVersion;
5006
5085
  function pluginVersion() {
5007
5086
  if (cachedPluginVersion) return cachedPluginVersion;
@@ -8302,6 +8381,77 @@ async function ensureProxyServer(config, tools, sessionKeyForCalls, interceptCom
8302
8381
  return srv;
8303
8382
  }
8304
8383
 
8384
+ // src/mcp-hot-reload.ts
8385
+ var DEFAULT_MCP_HOT_RELOAD_COOLDOWN_MS = 6e4;
8386
+ var MAX_COOLDOWN_ENTRIES = 64;
8387
+ var lastReloadAt = /* @__PURE__ */ new Map();
8388
+ function resolveMcpHotReloadCooldownMs() {
8389
+ const raw = process.env.CLAUDE_CODE_MCP_HOT_RELOAD_COOLDOWN_MS;
8390
+ if (raw === void 0) return DEFAULT_MCP_HOT_RELOAD_COOLDOWN_MS;
8391
+ const parsed = Number(raw);
8392
+ if (!Number.isFinite(parsed) || parsed < 0) {
8393
+ return DEFAULT_MCP_HOT_RELOAD_COOLDOWN_MS;
8394
+ }
8395
+ return parsed;
8396
+ }
8397
+ function noteMcpHotReload(sessionKey2, now = Date.now()) {
8398
+ lastReloadAt.delete(sessionKey2);
8399
+ lastReloadAt.set(sessionKey2, now);
8400
+ while (lastReloadAt.size > MAX_COOLDOWN_ENTRIES) {
8401
+ const oldest = lastReloadAt.keys().next();
8402
+ if (oldest.done) break;
8403
+ lastReloadAt.delete(oldest.value);
8404
+ }
8405
+ }
8406
+ function difference(a, b) {
8407
+ const other = new Set(b);
8408
+ return a.filter((name) => !other.has(name)).sort();
8409
+ }
8410
+ function decideMcpHotReload(input) {
8411
+ const previousServers = input.previousServers ?? [];
8412
+ const joined = difference(input.currentServers, previousServers);
8413
+ const left = difference(previousServers, input.currentServers);
8414
+ const decide = (verdict) => ({
8415
+ verdict,
8416
+ reload: verdict === "reload",
8417
+ joined,
8418
+ left
8419
+ });
8420
+ if (!input.enabled) return decide("off");
8421
+ if (input.compactionMode) return decide("skipped-compaction");
8422
+ if (!input.hasActiveProcess) return decide("no-process");
8423
+ const previousHash = input.previousHash ?? null;
8424
+ if (previousHash === input.currentHash) return decide("unchanged");
8425
+ if (input.interactive) return decide("skipped-interactive");
8426
+ if (input.pendingProxyCalls > 0) return decide("deferred-proxy-calls");
8427
+ if (input.turnInFlight) return decide("deferred-turn-in-flight");
8428
+ if (input.planQuestionPending) return decide("deferred-plan-question");
8429
+ const cooldownMs = input.cooldownMs ?? resolveMcpHotReloadCooldownMs();
8430
+ const last = lastReloadAt.get(input.sessionKey);
8431
+ if (cooldownMs > 0 && last !== void 0) {
8432
+ const now = input.now ?? Date.now();
8433
+ if (now - last < cooldownMs) return decide("skipped-cooldown");
8434
+ }
8435
+ return decide("reload");
8436
+ }
8437
+ function logMcpHotReloadDecision(decision, context) {
8438
+ if (decision.verdict === "off" || decision.verdict === "no-process" || decision.verdict === "skipped-compaction" || decision.verdict === "unchanged") {
8439
+ return;
8440
+ }
8441
+ const details = {
8442
+ sk: context.sessionKey,
8443
+ joined: decision.joined,
8444
+ left: decision.left,
8445
+ previousHash: context.previousHash ?? null,
8446
+ currentHash: context.currentHash
8447
+ };
8448
+ if (decision.reload) {
8449
+ log.info("opencode MCP servers changed, respawning claude", details);
8450
+ return;
8451
+ }
8452
+ log.info(`opencode MCP servers changed; holding off (${decision.verdict})`, details);
8453
+ }
8454
+
8305
8455
  // src/title.ts
8306
8456
  function isTitleRequest(config, scope, options) {
8307
8457
  if (scope === "no-tools") return true;
@@ -10490,7 +10640,15 @@ var ClaudeCodeLanguageModel = class {
10490
10640
  (m) => m.result !== null
10491
10641
  );
10492
10642
  const [runtimeStatus, cliVersion] = await Promise.all([
10493
- compactionMode ? Promise.resolve(void 0) : getRuntimeMcpStatus(),
10643
+ compactionMode ? Promise.resolve(void 0) : getRuntimeMcpStatus({
10644
+ // Give a server opencode is still starting a bounded moment to
10645
+ // reach a decision before the spawn is planned without it. Only
10646
+ // opencode 2 ever reports `pending`, so on 1.x this is one status
10647
+ // call exactly as before.
10648
+ waitForPendingMs: resolveMcpConnectWaitMs(
10649
+ this.config.mcpConnectWaitMs
10650
+ )
10651
+ }),
10494
10652
  detectCliVersion(cliPath)
10495
10653
  ]);
10496
10654
  const failoverAccounts = failoverCandidates(
@@ -10556,24 +10714,30 @@ var ClaudeCodeLanguageModel = class {
10556
10714
  if (!compactionMode && state.activeProcess && self.config.hotReloadMcp !== false && self.config.bridgeOpencodeMcp !== false) {
10557
10715
  const probe = self.effectiveMcpConfig(cwd, void 0, runtimeStatus);
10558
10716
  const previousHash = state.activeProcess.mcpHash ?? null;
10559
- if (previousHash !== probe.bridgedHash) {
10560
- if (previousPendingProxyCalls.length > 0) {
10561
- log.info("deferring MCP hot reload until proxy calls resolve", {
10562
- sk,
10563
- previousHash,
10564
- currentHash: probe.bridgedHash,
10565
- pendingCalls: previousPendingProxyCalls.length
10566
- });
10567
- } else {
10568
- log.info("opencode MCP config changed, respawning claude", {
10569
- sk,
10570
- previousHash,
10571
- currentHash: probe.bridgedHash
10572
- });
10573
- await deleteActiveProcessAndWait(sk);
10574
- state.activeProcess = void 0;
10575
- state.proxyServer = null;
10576
- }
10717
+ const decision = decideMcpHotReload({
10718
+ sessionKey: sk,
10719
+ enabled: true,
10720
+ hasActiveProcess: true,
10721
+ compactionMode,
10722
+ interactive: !!useInteractive,
10723
+ turnInFlight: isTurnInFlight(state.activeProcess),
10724
+ pendingProxyCalls: previousPendingProxyCalls.length,
10725
+ planQuestionPending: hasExitPlanModeQuestions(sk),
10726
+ previousHash,
10727
+ currentHash: probe.bridgedHash,
10728
+ previousServers: state.activeProcess.mcpServers,
10729
+ currentServers: probe.allEnabledServerNames
10730
+ });
10731
+ logMcpHotReloadDecision(decision, {
10732
+ sessionKey: sk,
10733
+ previousHash,
10734
+ currentHash: probe.bridgedHash
10735
+ });
10736
+ if (decision.reload) {
10737
+ noteMcpHotReload(sk);
10738
+ await deleteActiveProcessAndWait(sk);
10739
+ state.activeProcess = void 0;
10740
+ state.proxyServer = null;
10577
10741
  }
10578
10742
  }
10579
10743
  if (useInteractive && !compactionMode) {
@@ -10633,6 +10797,7 @@ var ClaudeCodeLanguageModel = class {
10633
10797
  effort: reasoningEffort
10634
10798
  });
10635
10799
  ap.mcpHash = mcp.bridgedHash;
10800
+ ap.mcpServers = mcp.allEnabledServerNames;
10636
10801
  setActiveProcess(sk, ap);
10637
10802
  state.proc = ap.proc;
10638
10803
  state.lineEmitter = ap.lineEmitter;
@@ -10648,6 +10813,7 @@ var ClaudeCodeLanguageModel = class {
10648
10813
  let spawnSystemPromptFile;
10649
10814
  let spawnProxyServer = null;
10650
10815
  let spawnMcpHash = null;
10816
+ let spawnMcpServers = [];
10651
10817
  if (compactionMode) {
10652
10818
  state.cliArgs = buildCliArgs({
10653
10819
  sessionKey: sk,
@@ -10821,6 +10987,7 @@ var ClaudeCodeLanguageModel = class {
10821
10987
  spawnSystemPromptFile = systemPromptFile;
10822
10988
  spawnProxyServer = state.proxyServer;
10823
10989
  spawnMcpHash = mcp.bridgedHash;
10990
+ spawnMcpServers = mcp.allEnabledServerNames;
10824
10991
  }
10825
10992
  if (state.activeProcess && !compactionMode) {
10826
10993
  state.proc = state.activeProcess.proc;
@@ -10839,6 +11006,7 @@ var ClaudeCodeLanguageModel = class {
10839
11006
  reasoningEffort,
10840
11007
  promptCacheTtl
10841
11008
  );
11009
+ ap.mcpServers = spawnMcpServers;
10842
11010
  state.proc = ap.proc;
10843
11011
  state.lineEmitter = ap.lineEmitter;
10844
11012
  state.activeProcess = ap;
@@ -12085,6 +12253,7 @@ function createClaudeCode(settings = {}) {
12085
12253
  planModeQuestion: settings.planModeQuestion ?? false,
12086
12254
  webSearch: settings.webSearch,
12087
12255
  hotReloadMcp: settings.hotReloadMcp ?? true,
12256
+ mcpConnectWaitMs: settings.mcpConnectWaitMs,
12088
12257
  proxyOpencodeMcpTools: settings.proxyOpencodeMcpTools === true,
12089
12258
  multiStepContinuation: settings.multiStepContinuation ?? true,
12090
12259
  autoContinueIncompleteTurns: settings.autoContinueIncompleteTurns ?? "smart",