@khalilgharbaoui/opencode-claude-code-plugin 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -677,7 +677,9 @@ The same events are also what let a legitimately long call complete, which is th
677
677
 
678
678
  ### Per-tool proxy timeouts
679
679
 
680
- Deadlines still exist, as an explicit backstop rather than the mechanism that decides when a call is over. If a tool with one has not been resolved within that many milliseconds, the call is rejected and Claude receives a timeout error. Resolved per tool, most-specific layer winning:
680
+ Deadlines still exist, as an explicit backstop rather than the mechanism that decides when a call is over. If a tool with one has not been resolved within that many milliseconds, the call is rejected and Claude receives a timeout error.
681
+
682
+ A deadline does not count time opencode is still spending on the call. When it passes, the plugin asks opencode whether the session is still busy. If it is (a permission prompt waiting for your answer, or the tool itself still running), the call keeps waiting and is checked again every minute. The deadline only applies once opencode is idle, or when opencode cannot be asked. Before this, answering a permission prompt after ten minutes meant Claude had already been told the command timed out. Your late approval then cancelled Claude's next action, which it reported as you rejecting it. Resolved per tool, most-specific layer winning:
681
683
 
682
684
  1. flat default — 10 min (matches Claude CLI's own Bash ceiling)
683
685
  2. per-tool default: **`task` / `task_batch`: none**, **`question`: 30 min**, everything else: 10 min
package/dist/index.js CHANGED
@@ -907,8 +907,9 @@ var QUESTION_TOOL_NAME = "question";
907
907
  var APPROVED_EXIT_PLAN_MODE_MESSAGE = "User has approved your plan. You can now start coding. Start with updating your todo list if applicable.";
908
908
  var REJECTED_EXIT_PLAN_MODE_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:";
909
909
  var PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?";
910
- var OPENCODE_QUESTION_RESULT_PREFIX = `User has answered your questions: "${PLAN_MODE_APPROVAL_QUESTION}"="`;
911
- var OPENCODE_QUESTION_RESULT_SUFFIX = `". You can now continue with the user's answers in mind.`;
910
+ var OPENCODE_QUESTION_RESULT_PREFIX = "User has answered your questions: ";
911
+ var OPENCODE_QUESTION_RESULT_SUFFIX = ". You can now continue with the user's answers in mind.";
912
+ var OPENCODE_UNANSWERED = "Unanswered";
912
913
  var KEY_SEPARATOR = "\0";
913
914
  function isPlanModeQuestionActive(input) {
914
915
  if (input.compactionMode) return false;
@@ -1007,29 +1008,40 @@ function unwrapToolOutput(part) {
1007
1008
  return output;
1008
1009
  }
1009
1010
  }
1010
- function unwrapOpencodeQuestionResult(value) {
1011
- if (value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) && value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
1012
- return value.slice(
1013
- OPENCODE_QUESTION_RESULT_PREFIX.length,
1014
- -OPENCODE_QUESTION_RESULT_SUFFIX.length
1015
- );
1011
+ function unwrapOpencodeQuestionResult(value, question) {
1012
+ if (!value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) || !value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
1013
+ return value;
1016
1014
  }
1017
- return value;
1015
+ const body = value.slice(
1016
+ OPENCODE_QUESTION_RESULT_PREFIX.length,
1017
+ value.length - OPENCODE_QUESTION_RESULT_SUFFIX.length
1018
+ );
1019
+ if (!body.startsWith('"') || !body.endsWith('"')) return value;
1020
+ let answer;
1021
+ const head = question === void 0 ? void 0 : `"${question}"="`;
1022
+ if (head && body.startsWith(head) && body.length > head.length) {
1023
+ answer = body.slice(head.length, -1);
1024
+ } else {
1025
+ const split = body.lastIndexOf('"="');
1026
+ if (split > 0) answer = body.slice(split + 3, -1);
1027
+ }
1028
+ if (answer === void 0) return value;
1029
+ return answer === OPENCODE_UNANSWERED ? "" : answer;
1018
1030
  }
1019
- function collectAnswerStrings(value) {
1020
- if (typeof value === "string") return [unwrapOpencodeQuestionResult(value)];
1021
- if (Array.isArray(value)) return value.flatMap(collectAnswerStrings);
1031
+ function collectAnswerStrings(value, question) {
1032
+ if (typeof value === "string") return [unwrapOpencodeQuestionResult(value, question)];
1033
+ if (Array.isArray(value)) return value.flatMap((item) => collectAnswerStrings(item, question));
1022
1034
  if (!value || typeof value !== "object") return [];
1023
1035
  const obj = value;
1024
1036
  if (obj.denied === true) return [String(obj.reason ?? "question rejected")];
1025
1037
  for (const key of ["answers", "answer", "selected", "selection", "value"]) {
1026
- if (key in obj) return collectAnswerStrings(obj[key]);
1038
+ if (key in obj) return collectAnswerStrings(obj[key], question);
1027
1039
  }
1028
1040
  return [];
1029
1041
  }
1030
1042
  function classifyQuestionResult(part) {
1031
1043
  const output = unwrapToolOutput(part);
1032
- const answers = collectAnswerStrings(output).map((answer) => answer.trim()).filter(Boolean);
1044
+ const answers = collectAnswerStrings(output, PLAN_MODE_APPROVAL_QUESTION).map((answer) => answer.trim()).filter(Boolean);
1033
1045
  if (answers.length === 1 && answers[0].toLowerCase() === "yes") {
1034
1046
  return { approved: true, feedback: "" };
1035
1047
  }
@@ -1175,17 +1187,18 @@ function createAccountFailoverQuestionCall(sessionKey2, input, toolCallId = `${A
1175
1187
  const candidates = input.candidates.map((c) => normalizeAccountName(c));
1176
1188
  const resets = describeReset(input.resetsAt);
1177
1189
  const until = resets ?? "opencode restarts";
1178
- pendingQuestions2.set(pendingKey2(sessionKey2, toolCallId), {
1179
- sourceAccount: source,
1180
- candidates: [...candidates],
1181
- resetsAt: input.resetsAt
1182
- });
1183
1190
  const question = [
1184
1191
  `The Claude account "${source}" is out of usage`,
1185
1192
  input.window ? ` in ${input.window}` : "",
1186
1193
  resets ? `, which resets at ${resets}` : "",
1187
1194
  ". Continue this task on another configured account? Leaving this unanswered waits, at no cost."
1188
1195
  ].join("");
1196
+ pendingQuestions2.set(pendingKey2(sessionKey2, toolCallId), {
1197
+ sourceAccount: source,
1198
+ candidates: [...candidates],
1199
+ resetsAt: input.resetsAt,
1200
+ question
1201
+ });
1189
1202
  return {
1190
1203
  toolCallId,
1191
1204
  toolName: QUESTION_TOOL_NAME,
@@ -1223,7 +1236,11 @@ function classify(pending, part) {
1223
1236
  reason: String(output.reason ?? "question rejected")
1224
1237
  };
1225
1238
  }
1226
- const answers = collectAnswerStrings(output).map((answer) => answer.trim()).filter(Boolean);
1239
+ const outputType = part?.output?.type;
1240
+ if (outputType === "error-text" || outputType === "error-json") {
1241
+ return { kind: "stop", reason: String(output ?? "the form was dismissed") };
1242
+ }
1243
+ const answers = collectAnswerStrings(output, pending.question).map((answer) => answer.trim()).filter(Boolean);
1227
1244
  if (answers.length === 0) return { kind: "stop", reason: "no answer" };
1228
1245
  const picked = normalizeAccountName(answers[0]);
1229
1246
  if (picked === STOP_ANSWER) {
@@ -1240,7 +1257,7 @@ function classify(pending, part) {
1240
1257
  resetsAt: pending.resetsAt
1241
1258
  };
1242
1259
  }
1243
- function consumeAccountFailoverAnswer(sessionKey2, prompt) {
1260
+ function consumeAccountFailoverAnswer(sessionKey2, prompt, fallback) {
1244
1261
  for (let i = prompt.length - 1; i >= 0; i--) {
1245
1262
  const msg = prompt[i];
1246
1263
  if (!Array.isArray(msg.content)) continue;
@@ -1255,6 +1272,21 @@ function consumeAccountFailoverAnswer(sessionKey2, prompt) {
1255
1272
  return classify(pending, part);
1256
1273
  }
1257
1274
  }
1275
+ const last = prompt[prompt.length - 1];
1276
+ if (fallback && fallback.candidates.length > 0 && Array.isArray(last?.content)) {
1277
+ const orphan = last.content.find(
1278
+ (part) => part?.type === "tool-result" && typeof part.toolCallId === "string" && part.toolCallId.startsWith(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX)
1279
+ );
1280
+ if (orphan) {
1281
+ return classify(
1282
+ {
1283
+ sourceAccount: normalizeAccountName(fallback.sourceAccount || DEFAULT_ACCOUNT),
1284
+ candidates: fallback.candidates.map((c) => normalizeAccountName(c))
1285
+ },
1286
+ orphan
1287
+ );
1288
+ }
1289
+ }
1258
1290
  return null;
1259
1291
  }
1260
1292
  function formatFailoverNote(input) {
@@ -1515,6 +1547,53 @@ function resolveProxyClientCeilingMs(overrides) {
1515
1547
  }
1516
1548
  return Math.min(ms, MAX_PROXY_TIMEOUT_MS);
1517
1549
  }
1550
+ var proxyDeadlineGuard = null;
1551
+ function setProxyDeadlineGuard(guard) {
1552
+ proxyDeadlineGuard = guard;
1553
+ }
1554
+ var PROXY_DEADLINE_RECHECK_MS = 6e4;
1555
+ var deadlineRecheckMs = PROXY_DEADLINE_RECHECK_MS;
1556
+ function armProxyDeadline(opts) {
1557
+ let cancelled = false;
1558
+ let extended = false;
1559
+ let timer = null;
1560
+ const check = async () => {
1561
+ timer = null;
1562
+ if (cancelled) return;
1563
+ let keep = false;
1564
+ const guard = proxyDeadlineGuard;
1565
+ if (guard) {
1566
+ try {
1567
+ keep = await guard({ callId: opts.callId, toolName: opts.toolName });
1568
+ } catch {
1569
+ keep = false;
1570
+ }
1571
+ }
1572
+ if (cancelled) return;
1573
+ if (keep) {
1574
+ if (!extended && opts.logExtension) {
1575
+ log.warn("proxy call past its deadline, but opencode is still serving it; waiting", {
1576
+ callId: opts.callId,
1577
+ toolName: opts.toolName,
1578
+ deadlineMs: opts.deadlineMs,
1579
+ note: "usually a permission prompt in opencode; the call ends when it is answered, you abort, or the claude process goes"
1580
+ });
1581
+ }
1582
+ extended = true;
1583
+ timer = setTimeout(() => void check(), opts.recheckMs ?? deadlineRecheckMs);
1584
+ return;
1585
+ }
1586
+ opts.onExpire();
1587
+ };
1588
+ timer = setTimeout(() => void check(), opts.deadlineMs);
1589
+ return {
1590
+ cancel() {
1591
+ cancelled = true;
1592
+ if (timer) clearTimeout(timer);
1593
+ timer = null;
1594
+ }
1595
+ };
1596
+ }
1518
1597
  function buildProxyTimeoutError(toolName, ms) {
1519
1598
  const key = toolName.toLowerCase();
1520
1599
  const base = `Proxy tool '${toolName}' timed out after ${ms}ms waiting for opencode to resolve the call`;
@@ -2000,21 +2079,26 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2000
2079
  timeoutOverrides
2001
2080
  );
2002
2081
  if (deadlineMs > PROXY_NO_DEADLINE_MS) {
2003
- timer = setTimeout(() => {
2004
- if (!pending.has(callId)) return;
2005
- pending.delete(callId);
2006
- log.notice("proxy-mcp tool call timed out", {
2007
- callId,
2008
- toolName,
2009
- deadlineMs
2010
- });
2011
- reject2(buildProxyTimeoutError(toolName, deadlineMs));
2012
- }, deadlineMs);
2082
+ timer = armProxyDeadline({
2083
+ callId,
2084
+ toolName,
2085
+ deadlineMs,
2086
+ onExpire: () => {
2087
+ if (!pending.has(callId)) return;
2088
+ pending.delete(callId);
2089
+ log.notice("proxy-mcp tool call timed out", {
2090
+ callId,
2091
+ toolName,
2092
+ deadlineMs
2093
+ });
2094
+ reject2(buildProxyTimeoutError(toolName, deadlineMs));
2095
+ }
2096
+ });
2013
2097
  }
2014
2098
  calls.emit("call", entry);
2015
2099
  }
2016
2100
  ).finally(() => {
2017
- if (timer) clearTimeout(timer);
2101
+ timer?.cancel();
2018
2102
  pending.delete(callId);
2019
2103
  });
2020
2104
  if (channel.closed) {
@@ -2381,7 +2465,7 @@ var PROXY_STALL_WARNING_MS = 5 * 6e4;
2381
2465
  var PROXY_DEADLINE_WARNING_FRACTION = 0.6;
2382
2466
  var PROXY_DEADLINE_WARNING_MIN_MS = 6e4;
2383
2467
  function clearPendingTimers(pending) {
2384
- if (pending.timer) clearTimeout(pending.timer);
2468
+ pending.timer?.cancel();
2385
2469
  if (pending.stallTimer) clearInterval(pending.stallTimer);
2386
2470
  if (pending.deadlineWarnTimer) clearTimeout(pending.deadlineWarnTimer);
2387
2471
  }
@@ -2425,20 +2509,26 @@ function queuePendingProxyCall(sessionKey2, call, timeoutOverrides, stallWarning
2425
2509
  call.input,
2426
2510
  timeoutOverrides
2427
2511
  );
2428
- const timer = deadlineMs > PROXY_NO_DEADLINE_MS ? setTimeout(() => {
2429
- const current = pendingByCallId.get(call.id);
2430
- if (!current) return;
2431
- pendingByCallId.delete(call.id);
2432
- indexRemove(current.sessionKey, call.id);
2433
- clearPendingTimers(current);
2434
- current.reject(buildProxyTimeoutError(call.toolName, deadlineMs));
2435
- log.notice("timed out pending proxy call", {
2436
- sessionKey: current.sessionKey,
2437
- toolCallId: call.id,
2438
- toolName: call.toolName,
2439
- deadlineMs
2440
- });
2441
- }, deadlineMs) : null;
2512
+ const timer = deadlineMs > PROXY_NO_DEADLINE_MS ? armProxyDeadline({
2513
+ callId: call.id,
2514
+ toolName: call.toolName,
2515
+ deadlineMs,
2516
+ logExtension: true,
2517
+ onExpire: () => {
2518
+ const current = pendingByCallId.get(call.id);
2519
+ if (!current) return;
2520
+ pendingByCallId.delete(call.id);
2521
+ indexRemove(current.sessionKey, call.id);
2522
+ clearPendingTimers(current);
2523
+ current.reject(buildProxyTimeoutError(call.toolName, deadlineMs));
2524
+ log.notice("timed out pending proxy call", {
2525
+ sessionKey: current.sessionKey,
2526
+ toolCallId: call.id,
2527
+ toolName: call.toolName,
2528
+ deadlineMs
2529
+ });
2530
+ }
2531
+ }) : null;
2442
2532
  const stallTimer = deadlineMs === PROXY_NO_DEADLINE_MS && stallWarningMs > 0 ? setInterval(() => {
2443
2533
  const current = pendingByCallId.get(call.id);
2444
2534
  if (!current) return;
@@ -2467,7 +2557,7 @@ function queuePendingProxyCall(sessionKey2, call, timeoutOverrides, stallWarning
2467
2557
  remainingMs: Math.max(0, deadlineMs - waitedMs),
2468
2558
  emitted: current.emitted === true,
2469
2559
  channelClosed: current.channel?.closed === true,
2470
- note: "it will be rejected when the deadline passes; raise this tool's proxyToolTimeoutMs if the work is legitimately this long"
2560
+ note: "it will be rejected when the deadline passes unless opencode is still serving it (a permission prompt is waiting); raise this tool's proxyToolTimeoutMs if the work is legitimately this long"
2471
2561
  });
2472
2562
  }, warnAtMs) : null;
2473
2563
  deadlineWarnTimer?.unref?.();
@@ -2502,6 +2592,9 @@ function markPendingProxyCallEmitted(toolCallId) {
2502
2592
  function isPendingProxyCallChannelClosed(call) {
2503
2593
  return call.channel?.closed === true;
2504
2594
  }
2595
+ function findPendingProxyCall(toolCallId) {
2596
+ return pendingByCallId.get(toolCallId);
2597
+ }
2505
2598
  function getPendingProxyCalls(sessionKey2) {
2506
2599
  const s = callIdsBySession.get(sessionKey2);
2507
2600
  if (!s || s.size === 0) return [];
@@ -4088,6 +4181,39 @@ async function fetchSessionParentId(sessionID) {
4088
4181
  return void 0;
4089
4182
  }
4090
4183
  }
4184
+ async function fetchSessionRunState(sessionID) {
4185
+ if (!sessionID || sessionID === "default") return "unknown";
4186
+ const status = opencodeClient?.session?.status;
4187
+ if (!status) return "unknown";
4188
+ try {
4189
+ const res = await status.call(opencodeClient.session);
4190
+ const data = res.data;
4191
+ if (!data || typeof data !== "object") return "unknown";
4192
+ const entry = data[sessionID];
4193
+ if (!entry || typeof entry !== "object") return "idle";
4194
+ const type = entry.type;
4195
+ return type === "idle" ? "idle" : "busy";
4196
+ } catch (err) {
4197
+ log.debug("failed to read opencode session status", {
4198
+ sessionID,
4199
+ error: err instanceof Error ? err.message : String(err)
4200
+ });
4201
+ return "unknown";
4202
+ }
4203
+ }
4204
+ async function settleSessionRunState(sessionID, options = {}) {
4205
+ const pollMs = options.pollMs ?? 150;
4206
+ const windowMs = options.windowMs ?? 1500;
4207
+ const started = Date.now();
4208
+ let last = "unknown";
4209
+ for (; ; ) {
4210
+ const state = await fetchSessionRunState(sessionID);
4211
+ if (state === "busy") return "busy";
4212
+ if (state !== "unknown") last = state;
4213
+ if (Date.now() - started >= windowMs) return last;
4214
+ await new Promise((resolve5) => setTimeout(resolve5, pollMs));
4215
+ }
4216
+ }
4091
4217
  async function getRuntimeMcpStatus() {
4092
4218
  const client = opencodeClient;
4093
4219
  if (!client?.mcp?.status) return void 0;
@@ -4821,6 +4947,7 @@ function getClaudeUserMessage(prompt, includeHistoryContext = false, opts = {})
4821
4947
  const compactionMode = opts.compactionMode === true;
4822
4948
  const cliToolCallIds = opts.cliToolCallIds;
4823
4949
  const content = [];
4950
+ prompt = stripAccountFailoverParts(prompt);
4824
4951
  if (opts.stripContextReminders) {
4825
4952
  const stripped = stripContextReminders(prompt);
4826
4953
  if (stripped.removed > 0) {
@@ -6165,6 +6292,13 @@ import { unlink as unlink3 } from "fs/promises";
6165
6292
  import { homedir as homedir5, tmpdir as tmpdir2 } from "os";
6166
6293
  import { randomUUID as randomUUID5 } from "crypto";
6167
6294
  import { dirname as dirname5, join as join8 } from "path";
6295
+ async function isProxyCallStillServed(callId) {
6296
+ const pending = findPendingProxyCall(callId);
6297
+ if (!pending || pending.emitted !== true) return false;
6298
+ const session = describeSessionKey(pending.sessionKey).session;
6299
+ return await fetchSessionRunState(session) === "busy";
6300
+ }
6301
+ setProxyDeadlineGuard(({ callId }) => isProxyCallStillServed(callId));
6168
6302
  var DEFAULT_COMPACTION_MODEL = "claude-haiku-4-5";
6169
6303
  function resolveCompactionModel(configured) {
6170
6304
  const env = process.env.CLAUDE_CODE_COMPACTION_MODEL?.trim();
@@ -6205,6 +6339,16 @@ function resolveOpencodeAgent(headers, providerOptions, providerKey) {
6205
6339
  }
6206
6340
  return void 0;
6207
6341
  }
6342
+ function describeAbortReason(reason) {
6343
+ if (reason === void 0) return "undefined";
6344
+ if (reason instanceof Error) return `${reason.name}: ${reason.message}`;
6345
+ if (typeof reason === "string") return reason;
6346
+ try {
6347
+ return JSON.stringify(reason) ?? typeof reason;
6348
+ } catch {
6349
+ return typeof reason;
6350
+ }
6351
+ }
6208
6352
  var KNOWN_DELTA_TYPES = /* @__PURE__ */ new Set([
6209
6353
  "thinking_delta",
6210
6354
  "text_delta",
@@ -7812,7 +7956,10 @@ ${plan}
7812
7956
  deleteActiveProcess(sk);
7813
7957
  clearCompression(sk);
7814
7958
  }
7815
- const failoverAnswer = compactionMode || useInteractive ? null : consumeAccountFailoverAnswer(sk, options.prompt);
7959
+ const failoverAnswer = compactionMode || useInteractive ? null : consumeAccountFailoverAnswer(sk, options.prompt, {
7960
+ sourceAccount,
7961
+ candidates: failoverCandidates(this.config.failoverAccounts, sourceAccount)
7962
+ });
7816
7963
  if (failoverAnswer?.kind === "stop") {
7817
7964
  log.warn("account failover declined; ending the turn", {
7818
7965
  sessionKey: sk,
@@ -9578,14 +9725,28 @@ ${plan}
9578
9725
  options.abortSignal.addEventListener("abort", () => {
9579
9726
  autoContinueState.aborted = true;
9580
9727
  if (turnCompleted || controllerClosed) {
9581
- if (activeProcess && activeProcess.lineEmitter.listenerCount("line") === 0 && getPendingProxyCalls(sk).length > 0) {
9582
- log.info("abort between proxy tool boundaries; releasing pending calls", { sk });
9583
- void interruptTurn(activeProcess).then((idle) => {
9584
- log.info("interrupt sent for aborted turn", { sk, idle });
9728
+ const stoppedProcess = activeProcess;
9729
+ if (stoppedProcess && stoppedProcess.lineEmitter.listenerCount("line") === 0 && getPendingProxyCalls(sk).length > 0) {
9730
+ const reason = describeAbortReason(options.abortSignal?.reason);
9731
+ void settleSessionRunState(affinity).then((stopped) => {
9732
+ const stillParked = stoppedProcess.lineEmitter.listenerCount("line") === 0 && getPendingProxyCalls(sk).length > 0;
9733
+ if (stopped === "busy" || !stillParked) {
9734
+ log.debug("abort at a tool boundary while opencode is still running the turn; keeping pending calls", {
9735
+ sk,
9736
+ session: stopped,
9737
+ stillParked,
9738
+ reason
9739
+ });
9740
+ return;
9741
+ }
9742
+ log.info("abort between proxy tool boundaries; releasing pending calls", { sk, reason });
9743
+ void interruptTurn(stoppedProcess).then((idle) => {
9744
+ log.info("interrupt sent for aborted turn", { sk, idle });
9745
+ });
9746
+ releaseAbandonedProxyCalls(
9747
+ "Provider stream was aborted while opencode was running its proxy tool calls"
9748
+ );
9585
9749
  });
9586
- releaseAbandonedProxyCalls(
9587
- "Provider stream was aborted while opencode was running its proxy tool calls"
9588
- );
9589
9750
  }
9590
9751
  return;
9591
9752
  }