@khalilgharbaoui/opencode-claude-code-plugin 0.54.1 → 0.55.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
@@ -1623,6 +1623,9 @@ function resolveAccountOverride(source, now = Date.now()) {
1623
1623
  }
1624
1624
  return entry.target;
1625
1625
  }
1626
+ function clearAccountOverride(source) {
1627
+ accountOverrides.delete(normalizeAccountName(source || DEFAULT_ACCOUNT));
1628
+ }
1626
1629
  function stripAccountSuffix(modelId) {
1627
1630
  const at = modelId.indexOf("@");
1628
1631
  return at === -1 ? modelId : modelId.slice(0, at);
@@ -1671,6 +1674,15 @@ function failoverCandidates(accounts, source, groups) {
1671
1674
  }
1672
1675
  return out;
1673
1676
  }
1677
+ function isAccountKnownUnavailable(account, now = Date.now()) {
1678
+ if (resolveAccountOverride(account, now) !== void 0) return true;
1679
+ return recallAccountLimit(account, now).resetsAt !== void 0;
1680
+ }
1681
+ function nextAutoFailoverAccount(candidates, tried, now = Date.now()) {
1682
+ return candidates.find(
1683
+ (account) => !tried.has(account) && !isAccountKnownUnavailable(account, now)
1684
+ );
1685
+ }
1674
1686
  var pendingQuestions2 = /* @__PURE__ */ new Map();
1675
1687
  function pendingKey2(sessionKey2, toolCallId) {
1676
1688
  return `${sessionKey2}${KEY_SEPARATOR2}${toolCallId}`;
@@ -1797,6 +1809,13 @@ function formatFailoverNote(input) {
1797
1809
  ${FAILOVER_MARKER} "${input.sourceAccount}" is out of usage, so this conversation continues on "${input.target}" ${resets ? `until ${resets}` : "until opencode restarts"}. Claude cannot resume a session across accounts, so the thread is being replayed into a fresh one.
1798
1810
  `;
1799
1811
  }
1812
+ function formatAutoFailoverNote(input) {
1813
+ const resets = describeReset(input.resetsAt);
1814
+ const arrival = input.carried ? "The conversation continues where it stopped." : "The conversation is replayed into a fresh Claude session there.";
1815
+ return `
1816
+ ${FAILOVER_MARKER} "${input.failedAccount}" ${input.reason ?? "is out of usage"}, so this turn moved to "${input.target}" automatically and stays there until ${resets ?? "opencode restarts"}. ${arrival}
1817
+ `;
1818
+ }
1800
1819
  function formatFailoverStopNote(reason) {
1801
1820
  return `
1802
1821
  ${FAILOVER_MARKER} Staying on this account (${reason}). The turn ends here; the usage limit is unchanged.
@@ -3564,6 +3583,10 @@ function cliHygieneEnv(inherited = process.env) {
3564
3583
  }
3565
3584
  return filled;
3566
3585
  }
3586
+ var SPAWN_MCP_TIMEOUT_MS = 12e4;
3587
+ function spawnMcpTimeoutEnv(inherited = process.env) {
3588
+ return inherited.MCP_TIMEOUT === void 0 ? { MCP_TIMEOUT: String(SPAWN_MCP_TIMEOUT_MS) } : {};
3589
+ }
3567
3590
  function detectCliVersion(cliPath) {
3568
3591
  const cached = cache.get(cliPath);
3569
3592
  if (cached) return cached;
@@ -6387,6 +6410,79 @@ function tailIsReplyOnly(history, from) {
6387
6410
  return true;
6388
6411
  }
6389
6412
 
6413
+ // src/conversation-gap.ts
6414
+ var served = /* @__PURE__ */ new Map();
6415
+ var servedSeq = 0;
6416
+ var MAX_SERVED_CONVERSATIONS = 64;
6417
+ function recordServedConversation(sessionKey2, prompt) {
6418
+ const chain = conversationDigests(prompt, { assistantContent: false });
6419
+ if (chain.length === 0) return;
6420
+ served.delete(sessionKey2);
6421
+ served.set(sessionKey2, { chain, seq: ++servedSeq });
6422
+ while (served.size > MAX_SERVED_CONVERSATIONS) {
6423
+ const oldest = served.keys().next();
6424
+ if (oldest.done) break;
6425
+ served.delete(oldest.value);
6426
+ }
6427
+ }
6428
+ function forgetServedConversation(sessionKey2) {
6429
+ served.delete(sessionKey2);
6430
+ }
6431
+ function moveServedConversation(fromKey, toKey) {
6432
+ const record = served.get(fromKey);
6433
+ if (!record) return;
6434
+ served.delete(fromKey);
6435
+ served.delete(toKey);
6436
+ served.set(toKey, { ...record, seq: ++servedSeq });
6437
+ }
6438
+ function findMissedMessages(sessionKey2, prompt) {
6439
+ const record = served.get(sessionKey2);
6440
+ if (!record) return null;
6441
+ const messages = prompt;
6442
+ let lastAssistant = -1;
6443
+ for (let i = messages.length - 1; i >= 0; i--) {
6444
+ if (messages[i].role === "assistant") {
6445
+ lastAssistant = i;
6446
+ break;
6447
+ }
6448
+ }
6449
+ if (lastAssistant < 0) return null;
6450
+ const history = [];
6451
+ for (let i = 0; i <= lastAssistant; i++) {
6452
+ const msg = messages[i];
6453
+ if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "tool") continue;
6454
+ const [entry] = conversationDigests([msg], { assistantContent: false });
6455
+ history.push({ index: i, role: entry.role, digest: entry.digest });
6456
+ }
6457
+ const recorded = record.chain;
6458
+ if (recorded.length === 0 || recorded.length > history.length) return null;
6459
+ for (let i = 0; i < recorded.length; i++) {
6460
+ if (recorded[i].role !== history[i].role) return null;
6461
+ if (recorded[i].digest !== history[i].digest) return null;
6462
+ }
6463
+ let firstUser = -1;
6464
+ for (let i = recorded.length; i < history.length; i++) {
6465
+ if (history[i].role === "user") {
6466
+ firstUser = i;
6467
+ break;
6468
+ }
6469
+ }
6470
+ if (firstUser < 0) return null;
6471
+ const from = history[firstUser].index;
6472
+ const missed = messages.slice(from, lastAssistant + 1);
6473
+ let userMessages = 0;
6474
+ for (let i = firstUser; i < history.length; i++) {
6475
+ if (history[i].role === "user") userMessages++;
6476
+ }
6477
+ log.debug("this claude session missed part of the conversation", {
6478
+ sessionKey: sessionKey2,
6479
+ recorded: recorded.length,
6480
+ missed: missed.length,
6481
+ userMessages
6482
+ });
6483
+ return { messages: missed, userMessages };
6484
+ }
6485
+
6390
6486
  // src/session-resume-store.ts
6391
6487
  import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
6392
6488
  import { homedir as homedir6 } from "os";
@@ -7014,7 +7110,10 @@ function claudeSpawnEnv(opts) {
7014
7110
  // Pin the child to the binary whose version we detected, and keep it off
7015
7111
  // non-essential network calls. Fills gaps only, so an explicit shell value
7016
7112
  // survives: see `cliHygieneEnv` for why the version has to hold still.
7017
- ...cliHygieneEnv()
7113
+ ...cliHygieneEnv(),
7114
+ // 120 s for MCP servers to start, so a subagent outlasts an opencode
7115
+ // stall instead of running without the proxy: see `spawnMcpTimeoutEnv`.
7116
+ ...spawnMcpTimeoutEnv()
7018
7117
  };
7019
7118
  if (opts?.effort) {
7020
7119
  env.CLAUDE_CODE_EFFORT_LEVEL = cliEffortLevel(opts.effort);
@@ -7314,6 +7413,7 @@ function deleteClaudeSessionId(key) {
7314
7413
  if (claudeSessionId) clearLedger(claudeSessionId);
7315
7414
  claudeSessions.delete(key);
7316
7415
  forgetForkFingerprint(key);
7416
+ forgetServedConversation(key);
7317
7417
  forgetResumePoint(key);
7318
7418
  }
7319
7419
  function transferClaudeSession(fromKey, toKey) {
@@ -7325,6 +7425,7 @@ function transferClaudeSession(fromKey, toKey) {
7325
7425
  setClaudeSessionId(toKey, claudeSessionId);
7326
7426
  moveResumePoint(fromKey, toKey);
7327
7427
  moveForkFingerprint(fromKey, toKey);
7428
+ moveServedConversation(fromKey, toKey);
7328
7429
  clearExitPlanModeQuestions(fromKey);
7329
7430
  clearAccountFailoverQuestions(fromKey);
7330
7431
  clearSubagentDispatchQuestions(fromKey);
@@ -7968,12 +8069,14 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
7968
8069
  "agent prompt cache ttl refused: unknown value",
7969
8070
  "auto-continuation stopped",
7970
8071
  "auto-continuing incomplete claude result",
8072
+ "automatic account switch skipped an account whose runtime could not be prepared",
7971
8073
  "btw: a turn is running but no claude process appeared for it",
7972
8074
  "btw: early aside failed; the message will ask again",
7973
8075
  "cannot interrupt this transport; waiting for the turn to end",
7974
8076
  "carried this conversation's claude transcript to the other account",
7975
8077
  "carrying the claude conversation over to the new session key",
7976
8078
  "claude --version output unparseable",
8079
+ "claude account cannot serve this turn; continuing it on the next account automatically",
7977
8080
  "claude account is out of usage; the turn ends with a note",
7978
8081
  "claude already registers a different skill under this name; not bridging ours",
7979
8082
  "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
@@ -8047,6 +8150,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8047
8150
  "interactiveBypass is ignored: skipPermissions governs the interactive transport too",
8048
8151
  "malformed data URI, skipping file part",
8049
8152
  "no active process to respawn (start watchdog); ending turn",
8153
+ "no configured account could take this turn; it ends here",
8050
8154
  "no proxyTools entry was recognised; nothing will be proxied this turn",
8051
8155
  "no stdout after envelope write; respawning claude process to resume conversation",
8052
8156
  "opencode 2 event subscription ended; deleted sessions keep their claude process until idle eviction",
@@ -8076,6 +8180,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8076
8180
  "remote URL images are not supported by Claude CLI, skipping",
8077
8181
  "replaying stdout the child emitted between turns",
8078
8182
  "replaying the conversation as text: no claude session was available to continue",
8183
+ "replaying the messages this claude session missed: the conversation continued under another session key",
8079
8184
  "replaying this conversation as text on the other account",
8080
8185
  "replaying this conversation rather than carrying it across accounts",
8081
8186
  "result fallback timer fired \u2014 closing stream without result event",
@@ -8527,8 +8632,8 @@ function redactLogLine(raw, context) {
8527
8632
  if (!parsed.data || Object.keys(parsed.data).length === 0) return head;
8528
8633
  return `${head} ${JSON.stringify(sanitizeLogData(parsed.data, context))}`;
8529
8634
  }
8530
- function readLogTail(path15, maxBytes = BUNDLE_TAIL_BYTES) {
8531
- const fd = openSync(path15, "r");
8635
+ function readLogTail(path14, maxBytes = BUNDLE_TAIL_BYTES) {
8636
+ const fd = openSync(path14, "r");
8532
8637
  try {
8533
8638
  const size = fstatSync(fd).size;
8534
8639
  const length = Math.min(size, maxBytes);
@@ -9830,8 +9935,10 @@ function clipWithMarker(text, max) {
9830
9935
  \u2026[truncated ${text.length - max} chars]`;
9831
9936
  }
9832
9937
  var MAX_REPLAY_MESSAGE_CHARS = 2e3;
9833
- var MAX_REPLAY_LAST_REPLY_CHARS = 12e3;
9834
9938
  var MAX_REPLAY_HEAD_CHARS = 1e3;
9939
+ var MAX_REPLAY_RECENT_MESSAGES = 6;
9940
+ var MAX_REPLAY_RECENT_MESSAGE_CHARS = 12e3;
9941
+ var MAX_REPLAY_TOTAL_CHARS = 18e4;
9835
9942
  function clipMiddle(text, max) {
9836
9943
  if (text.length <= max) return text;
9837
9944
  const head = Math.min(Math.floor(max / 4), MAX_REPLAY_HEAD_CHARS);
@@ -9895,39 +10002,58 @@ ${clipWithMarker(
9895
10002
  }
9896
10003
  return { text: lines.join("\n"), toolResultCount };
9897
10004
  }
10005
+ function isConversationMessage(msg) {
10006
+ return msg.role === "user" || msg.role === "assistant" || msg.role === "tool";
10007
+ }
9898
10008
  function compactConversationHistory(prompt, opts = {}) {
9899
10009
  const mode = opts.mode ?? "fresh-session";
9900
10010
  prompt = filterSideQuestionHistory(prompt);
9901
10011
  if (mode === "compaction") {
9902
10012
  return buildCompactionHistory(prompt);
9903
10013
  }
9904
- const conversationMessages = prompt.filter(
9905
- (m) => m.role === "user" || m.role === "assistant" || m.role === "tool"
9906
- );
10014
+ if (opts.messages) {
10015
+ const run = filterSideQuestionHistory(opts.messages).filter(isConversationMessage);
10016
+ return run.length === 0 ? null : renderReplay(run);
10017
+ }
10018
+ const conversationMessages = prompt.filter(isConversationMessage);
9907
10019
  if (conversationMessages.length <= 1) {
9908
10020
  return null;
9909
10021
  }
9910
- const historyParts = [];
9911
- let lastAssistant = -1;
9912
- for (let i = conversationMessages.length - 2; i >= 0; i--) {
9913
- if (conversationMessages[i].role === "assistant") {
9914
- lastAssistant = i;
9915
- break;
9916
- }
9917
- }
9918
- for (let i = 0; i < conversationMessages.length - 1; i++) {
9919
- const msg = conversationMessages[i];
10022
+ return renderReplay(conversationMessages.slice(0, -1));
10023
+ }
10024
+ function renderReplay(messages) {
10025
+ const entries = [];
10026
+ let total = 0;
10027
+ let rank = 0;
10028
+ let omitted = 0;
10029
+ for (let i = messages.length - 1; i >= 0; i--) {
10030
+ const msg = messages[i];
9920
10031
  const role = msg.role === "user" ? "User" : msg.role === "assistant" ? "Assistant" : "Tool";
9921
10032
  const { text } = renderMessageContentForCompaction(msg);
9922
- if (text.trim()) {
9923
- const max = i === lastAssistant ? MAX_REPLAY_LAST_REPLY_CHARS : MAX_REPLAY_MESSAGE_CHARS;
9924
- historyParts.push(`${role}: ${clipMiddle(text, max)}`);
10033
+ if (!text.trim()) continue;
10034
+ const max = rank < MAX_REPLAY_RECENT_MESSAGES ? MAX_REPLAY_RECENT_MESSAGE_CHARS : MAX_REPLAY_MESSAGE_CHARS;
10035
+ rank++;
10036
+ const entry = `${role}: ${clipMiddle(text, max)}`;
10037
+ if (total + entry.length > MAX_REPLAY_TOTAL_CHARS) {
10038
+ omitted = i + 1;
10039
+ break;
9925
10040
  }
10041
+ entries.push(entry);
10042
+ total += entry.length + 2;
9926
10043
  }
9927
- if (historyParts.length === 0) {
9928
- return null;
10044
+ if (entries.length === 0) return null;
10045
+ entries.reverse();
10046
+ if (omitted > 0) {
10047
+ log.info("replay bounded: the oldest messages were dropped", {
10048
+ omitted,
10049
+ kept: entries.length,
10050
+ chars: total
10051
+ });
10052
+ entries.unshift(
10053
+ `[${omitted} earlier message${omitted === 1 ? "" : "s"} omitted: the replay is capped at ${MAX_REPLAY_TOTAL_CHARS} characters]`
10054
+ );
9929
10055
  }
9930
- return historyParts.join("\n\n");
10056
+ return entries.join("\n\n");
9931
10057
  }
9932
10058
  function buildCompactionHistory(prompt) {
9933
10059
  const entries = [];
@@ -10031,6 +10157,27 @@ ${historyContext}
10031
10157
 
10032
10158
  Now continuing with the current message:
10033
10159
 
10160
+ `
10161
+ });
10162
+ }
10163
+ } else if (opts.missed && opts.missed.length > 0) {
10164
+ const missed = compactConversationHistory(prompt, { messages: opts.missed });
10165
+ if (missed) {
10166
+ log.info("including the messages this claude session missed", {
10167
+ messages: opts.missed.length,
10168
+ chars: missed.length
10169
+ });
10170
+ content.push({
10171
+ type: "text",
10172
+ text: `<missed_conversation>
10173
+ The conversation continued under another opencode agent or model while this session was not asked. These are the messages exchanged since your last reply, replayed as text. They are part of this conversation: decisions and plans made there stand, and work done there is done.
10174
+
10175
+ ${missed}
10176
+
10177
+ </missed_conversation>
10178
+
10179
+ Now continuing with the current message:
10180
+
10034
10181
  `
10035
10182
  });
10036
10183
  }
@@ -10668,6 +10815,7 @@ function interactiveSpawnEnv(opts) {
10668
10815
  // detected version the flag gates read, and skip non-essential traffic.
10669
10816
  // Fills gaps only, so a var the user exported survives untouched.
10670
10817
  ...cliHygieneEnv(),
10818
+ ...spawnMcpTimeoutEnv(),
10671
10819
  ...opts.ignoreAnthropicApiKey ? { ANTHROPIC_API_KEY: void 0, ANTHROPIC_AUTH_TOKEN: void 0 } : {},
10672
10820
  ...opts.effort ? { CLAUDE_CODE_EFFORT_LEVEL: opts.effort } : {}
10673
10821
  };
@@ -12305,9 +12453,9 @@ import { readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "
12305
12453
  import { homedir as homedir11 } from "os";
12306
12454
  import { randomUUID as randomUUID7 } from "crypto";
12307
12455
  import { basename as basename2, dirname as dirname6, isAbsolute as isAbsolute3, join as join15 } from "path";
12308
- function readPromptFileIfPresent(path15) {
12456
+ function readPromptFileIfPresent(path14) {
12309
12457
  try {
12310
- const content = readFileSync9(path15, "utf8").trim();
12458
+ const content = readFileSync9(path14, "utf8").trim();
12311
12459
  return content || void 0;
12312
12460
  } catch {
12313
12461
  return void 0;
@@ -12425,9 +12573,9 @@ function extractSystemMessages(prompt) {
12425
12573
  }
12426
12574
  var CLAUDE_NATIVE_INSTRUCTION_FILES = /* @__PURE__ */ new Set(["CLAUDE.md", "CLAUDE.local.md"]);
12427
12575
  function userAuthoredInstructions(forwarded, opts = {}) {
12428
- const readFile3 = opts.readFile ?? ((path15) => {
12576
+ const readFile3 = opts.readFile ?? ((path14) => {
12429
12577
  try {
12430
- return readFileSync9(path15, "utf8");
12578
+ return readFileSync9(path14, "utf8");
12431
12579
  } catch {
12432
12580
  return null;
12433
12581
  }
@@ -12439,16 +12587,16 @@ function userAuthoredInstructions(forwarded, opts = {}) {
12439
12587
  }
12440
12588
  const seen = /* @__PURE__ */ new Set();
12441
12589
  for (const match of forwarded.matchAll(/^Instructions from: (.+)$/gm)) {
12442
- const path15 = match[1].trim();
12443
- if (seen.has(path15) || !isAbsolute3(path15)) continue;
12444
- if (CLAUDE_NATIVE_INSTRUCTION_FILES.has(basename2(path15))) continue;
12445
- seen.add(path15);
12446
- const file = readFile3(path15);
12590
+ const path14 = match[1].trim();
12591
+ if (seen.has(path14) || !isAbsolute3(path14)) continue;
12592
+ if (CLAUDE_NATIVE_INSTRUCTION_FILES.has(basename2(path14))) continue;
12593
+ seen.add(path14);
12594
+ const file = readFile3(path14);
12447
12595
  if (!file?.trim()) continue;
12448
12596
  const start = match.index + match[0].length + 1;
12449
12597
  const content = forwarded.startsWith(file, start) ? file : forwarded.startsWith(file.trimEnd(), start) ? file.trimEnd() : null;
12450
12598
  if (content === null) continue;
12451
- out.push({ text: `Instructions from: ${path15}
12599
+ out.push({ text: `Instructions from: ${path14}
12452
12600
  ${content}`, content });
12453
12601
  }
12454
12602
  return out;
@@ -12485,10 +12633,10 @@ ${options.compressionSummary.trim()}`
12485
12633
  if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
12486
12634
  const content = parts.join("\n\n");
12487
12635
  if (!content) return void 0;
12488
- const path15 = join15(pluginTmpDir(), `opencode-cc-sys-${randomUUID7()}.md`);
12636
+ const path14 = join15(pluginTmpDir(), `opencode-cc-sys-${randomUUID7()}.md`);
12489
12637
  try {
12490
- writeFileSync8(path15, content, { encoding: "utf8", mode: 384 });
12491
- return path15;
12638
+ writeFileSync8(path14, content, { encoding: "utf8", mode: 384 });
12639
+ return path14;
12492
12640
  } catch (err) {
12493
12641
  log.warn("failed to write system prompt file", { error: String(err) });
12494
12642
  return void 0;
@@ -14506,6 +14654,7 @@ ${plan}
14506
14654
  resultText: msg.message.content.filter((b) => b.type === "text" && b.text).map((b) => String(b.text)).join("\n")
14507
14655
  }));
14508
14656
  const limitErrorReply = limitFromReply && ctx.usageLimitNoteActive;
14657
+ const autoFailoverReply = (limitFromReply || accountBlockKind(msg) !== null) && ctx.autoFailoverTarget() !== void 0;
14509
14658
  const compactionApiError = ctx.compactionMode && (apiErrorReply || typeof msg.error === "string");
14510
14659
  if (limitFromReply) {
14511
14660
  state.accountLimitHit ??= recallAccountLimit(ctx.sourceAccount);
@@ -14527,7 +14676,7 @@ ${plan}
14527
14676
  }
14528
14677
  for (const block2 of msg.message.content) {
14529
14678
  if (block2.type === "text" && block2.text) {
14530
- if (limitErrorReply) continue;
14679
+ if (limitErrorReply || autoFailoverReply) continue;
14531
14680
  if (compactionApiError) {
14532
14681
  state.compactionFailureText ??= block2.text;
14533
14682
  continue;
@@ -14755,9 +14904,11 @@ ${plan}
14755
14904
  })) {
14756
14905
  state.accountLimitHit = recallAccountLimit(ctx.sourceAccount);
14757
14906
  }
14907
+ const autoFailoverResult = msg.is_error === true && (!!state.accountLimitHit || !!state.accountBlock) && ctx.autoFailoverTarget() !== void 0;
14758
14908
  state.usageLimitNote = ctx.usageLimitNoteActive && !!state.accountLimitHit && // An expired login or a billing hold writes its own note just below
14759
14909
  // and says what to run; two notes about one failure is one too many.
14760
- !state.accountBlock && msg.is_error === true;
14910
+ !state.accountBlock && msg.is_error === true && // Moved to another account: that account's answer is this turn's.
14911
+ !autoFailoverResult;
14761
14912
  const compactionFailure = ctx.compactionMode && (msg.is_error === true || typeof msg.subtype === "string" && msg.subtype !== "success");
14762
14913
  if (compactionFailure) {
14763
14914
  if (typeof msg.result === "string" && msg.result.trim()) {
@@ -14766,7 +14917,7 @@ ${plan}
14766
14917
  state.resultFailure ??= state.accountBlock ?? (state.accountLimitHit ? "usage_limit" : void 0) ?? (typeof msg.subtype === "string" && msg.subtype !== "success" ? msg.subtype : "error");
14767
14918
  }
14768
14919
  if (!state.currentTextId && msg.is_error && !compactionFailure && // Already on screen from the reply frame, word for word.
14769
- !(typeof msg.result === "string" && state.apiErrorTextShown !== null && msg.result.trim() === state.apiErrorTextShown.trim()) && !state.usageLimitNote && typeof msg.result === "string" && msg.result.trim().length > 0) {
14920
+ !(typeof msg.result === "string" && state.apiErrorTextShown !== null && msg.result.trim() === state.apiErrorTextShown.trim()) && !state.usageLimitNote && !autoFailoverResult && typeof msg.result === "string" && msg.result.trim().length > 0) {
14770
14921
  const errId = state.startTextBlock();
14771
14922
  state.controller.enqueue({
14772
14923
  type: "text-delta",
@@ -14780,14 +14931,14 @@ ${plan}
14780
14931
  if (state.accountBlock && msg.is_error) {
14781
14932
  state.resultFailure ??= state.accountBlock;
14782
14933
  const offeringSwitch = ctx.failoverAskActive;
14783
- if (!compactionFailure) {
14934
+ if (!compactionFailure && !autoFailoverResult) {
14784
14935
  state.controller.enqueue({
14785
14936
  type: "text-delta",
14786
14937
  id: state.startTextBlock(),
14787
14938
  delta: formatAccountBlockNote({
14788
14939
  kind: state.accountBlock,
14789
14940
  account: ctx.sourceAccount,
14790
- configDir: ctx.config.configDir,
14941
+ configDir: ctx.accountConfigDir,
14791
14942
  offeringSwitch,
14792
14943
  // Read only when the form is not taking the turn, which is the
14793
14944
  // default: moving the work by hand is then the operator's only
@@ -14806,7 +14957,7 @@ ${plan}
14806
14957
  const failure = describeResultFailure(msg);
14807
14958
  if (failure) {
14808
14959
  state.resultFailure = msg.subtype;
14809
- if (!compactionFailure) {
14960
+ if (!compactionFailure && !autoFailoverResult) {
14810
14961
  state.controller.enqueue({
14811
14962
  type: "text-delta",
14812
14963
  id: state.startTextBlock(),
@@ -15304,12 +15455,93 @@ var ClaudeCodeLanguageModel = class {
15304
15455
  * because which method opencode used is not in them.
15305
15456
  */
15306
15457
  async streamTurn(options, mode) {
15307
- const result = await this.runModelChain(options, mode);
15458
+ const result = await this.runAccountFailover(options, mode);
15308
15459
  return {
15309
15460
  ...result,
15310
15461
  stream: translateStreamForHost(result.stream, this.config.hostApi ?? "v1")
15311
15462
  };
15312
15463
  }
15464
+ /**
15465
+ * Run the turn, moving the same request to the next eligible configured
15466
+ * account when the one it ran on is out of usage or cannot serve
15467
+ * (`accountFailover: "auto"`). The single wiring point for the automatic
15468
+ * switch, and outside `runModelChain` on purpose: an account switch starts
15469
+ * that account's own model chain from the top.
15470
+ *
15471
+ * Anything but `"auto"` is `runModelChain` and one `await`, so `"off"` and
15472
+ * `"ask"` are exactly what they were. With `"auto"`, the attempt that failed
15473
+ * decides the switch itself (`completeResult` sets `run.switchTo` and closes
15474
+ * its stream without a `finish`), and the next attempt's prologue makes the
15475
+ * switch the form's pick would have made. This only splices the attempts
15476
+ * into one caller turn: one `stream-start`, and the `finish` of whichever
15477
+ * attempt did not switch.
15478
+ *
15479
+ * The bound is `run.tried`, as it is `tried` for the model chain: every
15480
+ * account spawned or switched to goes in, and none in it is picked again.
15481
+ *
15482
+ * Cancelling the returned stream is a stop, for every attempt: this loop is
15483
+ * the plugin starting more work on the caller's behalf, so a caller that
15484
+ * has gone away must reach all of it. Each attempt gets a signal that the
15485
+ * caller's own `abortSignal` and a `cancel()` both abort, and the loop
15486
+ * starts no further account once either has happened.
15487
+ */
15488
+ async runAccountFailover(options, mode) {
15489
+ if (this.config.accountFailover !== "auto") return this.runModelChain(options, mode);
15490
+ const self = this;
15491
+ const run = { tried: /* @__PURE__ */ new Set() };
15492
+ const cancelled2 = new AbortController();
15493
+ const attemptOptions = {
15494
+ ...options,
15495
+ abortSignal: options.abortSignal ? AbortSignal.any([options.abortSignal, cancelled2.signal]) : cancelled2.signal
15496
+ };
15497
+ const stopped = () => attemptOptions.abortSignal.aborted;
15498
+ let inner = await this.runModelChain(attemptOptions, mode, run);
15499
+ const request = inner.request;
15500
+ let reader;
15501
+ const stream = new ReadableStream({
15502
+ async start(controller) {
15503
+ let started2 = false;
15504
+ try {
15505
+ for (; ; ) {
15506
+ reader = inner.stream.getReader();
15507
+ try {
15508
+ for (; ; ) {
15509
+ const { done, value } = await reader.read();
15510
+ if (done) break;
15511
+ if (value.type === "stream-start") {
15512
+ if (started2) continue;
15513
+ started2 = true;
15514
+ }
15515
+ controller.enqueue(value);
15516
+ }
15517
+ } finally {
15518
+ reader.releaseLock();
15519
+ }
15520
+ if (!run.switchTo) break;
15521
+ if (stopped()) {
15522
+ log.info("turn stopped before the automatic account switch; not switching", {
15523
+ target: run.switchTo.target
15524
+ });
15525
+ break;
15526
+ }
15527
+ inner = await self.runModelChain(attemptOptions, mode, run);
15528
+ }
15529
+ } catch (error) {
15530
+ if (!stopped()) controller.enqueue({ type: "error", error });
15531
+ } finally {
15532
+ try {
15533
+ controller.close();
15534
+ } catch {
15535
+ }
15536
+ }
15537
+ },
15538
+ async cancel(reason) {
15539
+ cancelled2.abort(reason);
15540
+ await reader?.cancel(reason).catch(() => void 0);
15541
+ }
15542
+ });
15543
+ return { stream, request };
15544
+ }
15313
15545
  /**
15314
15546
  * Run the turn, moving to the next model in the fallback chain if the one
15315
15547
  * it started on is refused. The single wiring point for `src/model-fallback.ts`.
@@ -15328,14 +15560,14 @@ var ClaudeCodeLanguageModel = class {
15328
15560
  * and an exhausted chain leaves the last attempt un-armed so its error
15329
15561
  * surfaces exactly as it does today.
15330
15562
  */
15331
- async runModelChain(options, mode) {
15563
+ async runModelChain(options, mode, accountRun) {
15332
15564
  const agent = this.getOpencodeAgent(options);
15333
15565
  const chain = this.isCompactionCall(options) ? [] : resolveFallbackChain(agent, resolveAgentModel(agent, this.modelId));
15334
- if (chain.length === 0) return this.doStreamForHost(options, void 0, mode);
15566
+ if (chain.length === 0) return this.doStreamForHost(options, void 0, mode, accountRun);
15335
15567
  const self = this;
15336
15568
  const tried = /* @__PURE__ */ new Set();
15337
15569
  let attempt = { armed: true };
15338
- let inner = await this.doStreamForHost(options, attempt, mode);
15570
+ let inner = await this.doStreamForHost(options, attempt, mode, accountRun);
15339
15571
  const request = inner.request;
15340
15572
  const stream = new ReadableStream({
15341
15573
  async start(controller) {
@@ -15405,7 +15637,7 @@ var ClaudeCodeLanguageModel = class {
15405
15637
  modelOverride: next,
15406
15638
  armed: nextFallbackModel(chain, tried) !== void 0
15407
15639
  };
15408
- inner = await self.doStreamForHost(options, attempt, mode);
15640
+ inner = await self.doStreamForHost(options, attempt, mode, accountRun);
15409
15641
  }
15410
15642
  } catch (error) {
15411
15643
  controller.enqueue({ type: "error", error });
@@ -15419,8 +15651,10 @@ var ClaudeCodeLanguageModel = class {
15419
15651
  });
15420
15652
  return { stream, request };
15421
15653
  }
15422
- async doStreamForHost(options, attempt, mode = "stream") {
15654
+ async doStreamForHost(options, attempt, mode = "stream", accountRun) {
15423
15655
  const warnings = [];
15656
+ const autoSwitch = accountRun?.switchTo;
15657
+ if (accountRun) accountRun.switchTo = void 0;
15424
15658
  const turnAbort = watchTurnAbort(options.abortSignal);
15425
15659
  const abortedBeforeWork = (stage) => {
15426
15660
  log.info("abort before the turn asked claude for work; nothing spawned", {
@@ -15660,12 +15894,17 @@ var ClaudeCodeLanguageModel = class {
15660
15894
  });
15661
15895
  }
15662
15896
  const hasPriorConversation = options.prompt.filter((m) => m.role === "user" || m.role === "assistant").length > 1;
15663
- if (!hasPriorConversation) {
15897
+ if (!hasPriorConversation && !autoSwitch) {
15664
15898
  deleteClaudeSessionId(sk);
15665
15899
  deleteActiveProcess(sk);
15666
15900
  clearCompression(sk);
15667
15901
  }
15668
- const failoverAnswer = compactionMode || mode === "generate" ? null : consumeAccountFailoverAnswer(sk, options.prompt, {
15902
+ const failoverAnswer = autoSwitch ? {
15903
+ kind: "switch",
15904
+ target: autoSwitch.target,
15905
+ sourceAccount: autoSwitch.sourceAccount,
15906
+ resetsAt: autoSwitch.resetsAt
15907
+ } : compactionMode || mode === "generate" ? null : consumeAccountFailoverAnswer(sk, options.prompt, {
15669
15908
  sourceAccount,
15670
15909
  candidates: failoverCandidates(
15671
15910
  this.config.failoverAccounts,
@@ -15723,13 +15962,84 @@ var ClaudeCodeLanguageModel = class {
15723
15962
  cliPath: this.config.cliPath,
15724
15963
  modelId: effectiveModelId
15725
15964
  });
15965
+ while (autoSwitch && accountRun && failover.target !== failoverAnswer.target) {
15966
+ log.notice("automatic account switch skipped an account whose runtime could not be prepared", {
15967
+ sessionKey: sk,
15968
+ target: failoverAnswer.target
15969
+ });
15970
+ const next = nextAutoFailoverAccount(
15971
+ failoverCandidates(this.config.failoverAccounts, sourceAccount, groups),
15972
+ accountRun.tried
15973
+ );
15974
+ if (!next) {
15975
+ clearAccountOverride(failoverAnswer.sourceAccount);
15976
+ log.warn("no configured account could take this turn; it ends here", {
15977
+ sessionKey: sk,
15978
+ account: autoSwitch.failedAccount,
15979
+ cause: autoSwitch.cause
15980
+ });
15981
+ const note = mode === "generate" ? null : autoSwitch.cause === "usage_limit" ? formatUsageLimitNote({
15982
+ sourceAccount: autoSwitch.failedAccount,
15983
+ candidates: [],
15984
+ resetsAt: autoSwitch.resetsAt,
15985
+ window: autoSwitch.window
15986
+ }) : formatAccountBlockNote({
15987
+ kind: autoSwitch.cause,
15988
+ account: autoSwitch.failedAccount,
15989
+ configDir: autoSwitch.failedConfigDir,
15990
+ offeringSwitch: false,
15991
+ candidates: []
15992
+ });
15993
+ const cause = autoSwitch.cause;
15994
+ const exhausted = new ReadableStream({
15995
+ start(controller) {
15996
+ controller.enqueue({ type: "stream-start", warnings });
15997
+ if (note) {
15998
+ const id = generateId();
15999
+ controller.enqueue({ type: "text-start", id });
16000
+ controller.enqueue({ type: "text-delta", id, delta: note });
16001
+ controller.enqueue({ type: "text-end", id });
16002
+ }
16003
+ controller.enqueue({
16004
+ type: "finish",
16005
+ finishReason: { unified: "error", raw: cause },
16006
+ usage: toUsage2({ input_tokens: 0, output_tokens: 0 }),
16007
+ providerMetadata: {
16008
+ "claude-code": {
16009
+ path: "account-failover-exhausted",
16010
+ synthetic: true,
16011
+ usageUnavailable: true
16012
+ }
16013
+ }
16014
+ });
16015
+ controller.close();
16016
+ }
16017
+ });
16018
+ return { stream: exhausted, request: { body: { text: "" } } };
16019
+ }
16020
+ accountRun.tried.add(next);
16021
+ failoverAnswer.target = next;
16022
+ setAccountOverride(
16023
+ failoverAnswer.sourceAccount,
16024
+ next,
16025
+ failoverUntil(failoverAnswer.resetsAt)
16026
+ );
16027
+ failover = await resolveFailoverSpawn({
16028
+ account: sourceAccount,
16029
+ baseCliPath,
16030
+ cliPath: this.config.cliPath,
16031
+ modelId: effectiveModelId
16032
+ });
16033
+ }
15726
16034
  cliPath = failover.cliPath;
15727
16035
  asideTransportRef.cliPath = cliPath;
15728
- failoverNote = formatFailoverNote({
15729
- sourceAccount: failoverAnswer.sourceAccount,
15730
- target: failoverAnswer.target,
15731
- resetsAt: failoverAnswer.resetsAt
15732
- });
16036
+ if (!autoSwitch) {
16037
+ failoverNote = formatFailoverNote({
16038
+ sourceAccount: failoverAnswer.sourceAccount,
16039
+ target: failoverAnswer.target,
16040
+ resetsAt: failoverAnswer.resetsAt
16041
+ });
16042
+ }
15733
16043
  }
15734
16044
  const spawnConfigDir = this.config.accountInProcess ? this.skillBridgeSpawn(failover).configDir : void 0;
15735
16045
  const processForAccount = getActiveProcess(sk);
@@ -15793,6 +16103,15 @@ var ClaudeCodeLanguageModel = class {
15793
16103
  if (failoverAnswer?.kind === "switch" && hasPriorConversation && !conversationOnTarget) {
15794
16104
  includeHistoryContext = true;
15795
16105
  }
16106
+ if (autoSwitch && failoverAnswer?.kind === "switch" && mode !== "generate") {
16107
+ failoverNote = formatAutoFailoverNote({
16108
+ failedAccount: autoSwitch.failedAccount,
16109
+ target: failoverAnswer.target,
16110
+ resetsAt: autoSwitch.resetsAt,
16111
+ reason: autoSwitch.reason,
16112
+ carried: conversationOnTarget
16113
+ });
16114
+ }
15796
16115
  let accountGroupNote = null;
15797
16116
  if (!compactionMode && groups && accountProviders.size > 0 && includeHistoryContext && failoverAnswer?.kind !== "switch") {
15798
16117
  const foreign = findForeignAccountSibling({
@@ -15965,6 +16284,14 @@ var ClaudeCodeLanguageModel = class {
15965
16284
  messages: options.prompt.length
15966
16285
  });
15967
16286
  }
16287
+ const missed = !compactionMode && !includeHistoryContext && failoverAnswer?.kind !== "switch" && !accountGroupNote && (!!getClaudeSessionId(sk) || !!getActiveProcess(sk)) ? findMissedMessages(sk, filterSideQuestionHistory(options.prompt)) : null;
16288
+ if (missed) {
16289
+ log.notice("replaying the messages this claude session missed: the conversation continued under another session key", {
16290
+ sessionKey: sk,
16291
+ messages: missed.messages.length,
16292
+ userMessages: missed.userMessages
16293
+ });
16294
+ }
15968
16295
  if (this.config.forkSessions === true && !compactionMode) {
15969
16296
  recordForkFingerprint(sk, options.prompt, cliPath);
15970
16297
  }
@@ -15985,7 +16312,8 @@ var ClaudeCodeLanguageModel = class {
15985
16312
  compactionMode,
15986
16313
  compactionInstructions: useInteractive && compactionMode ? extractSystemMessages(effectivePrompt).join("\n\n") : void 0,
15987
16314
  cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)),
15988
- stripContextReminders: this.stripContextRemindersEnabled()
16315
+ stripContextReminders: this.stripContextRemindersEnabled(),
16316
+ missed: missed?.messages
15989
16317
  });
15990
16318
  const ptyPlanMode = useInteractive && !compactionMode && this.config.permissionMode === "plan";
15991
16319
  const resolvedProxy = compactionMode ? null : ptyPlanMode ? this.resolvedProxyTools()?.filter((def) => def.name !== "write" && def.name !== "edit") ?? null : this.resolvedProxyTools();
@@ -16050,6 +16378,16 @@ var ClaudeCodeLanguageModel = class {
16050
16378
  // the operator is usually not even looking at.
16051
16379
  childSession: !!await fetchSessionParentId(affinity)
16052
16380
  });
16381
+ const spawnedAccount = failover.target ?? sourceAccount;
16382
+ accountRun?.tried.add(spawnedAccount);
16383
+ let autoFailoverChoice;
16384
+ const autoFailoverTarget = () => {
16385
+ if (autoFailoverChoice === void 0) {
16386
+ autoFailoverChoice = accountRun && !compactionMode ? nextAutoFailoverAccount(failoverAccounts, accountRun.tried) ?? null : null;
16387
+ }
16388
+ return autoFailoverChoice ?? void 0;
16389
+ };
16390
+ const limitAccount = accountRun ? spawnedAccount : sourceAccount;
16053
16391
  const usageLimitNoteActive = !compactionMode && mode !== "generate" && !failoverAskActive;
16054
16392
  let staleBuild = null;
16055
16393
  if (!compactionMode && mode !== "generate" && affinity !== "default") {
@@ -16690,6 +17028,9 @@ var ClaudeCodeLanguageModel = class {
16690
17028
  account: failover.target ?? sourceAccount
16691
17029
  });
16692
17030
  }
17031
+ if (!compactionMode) {
17032
+ recordServedConversation(sk, filterSideQuestionHistory(options.prompt));
17033
+ }
16693
17034
  if (deliverPendingCompletions(state)) {
16694
17035
  if (state.drainBuffer.length > 0) drainNow(state);
16695
17036
  return;
@@ -16727,6 +17068,36 @@ var ClaudeCodeLanguageModel = class {
16727
17068
  finishWithQuestionCall(state, call);
16728
17069
  return;
16729
17070
  }
17071
+ const autoTarget = (state.accountLimitHit || state.accountBlock) && msg.is_error === true ? autoFailoverTarget() : void 0;
17072
+ if (autoTarget && accountRun) {
17073
+ const target = autoTarget;
17074
+ accountRun.tried.add(target);
17075
+ accountRun.switchTo = {
17076
+ target,
17077
+ sourceAccount,
17078
+ failedAccount: limitAccount,
17079
+ cause: state.accountLimitHit ? "usage_limit" : state.accountBlock,
17080
+ resetsAt: state.accountLimitHit?.resetsAt,
17081
+ window: state.accountLimitHit?.window,
17082
+ failedConfigDir: self.skillBridgeSpawn(failover).configDir,
17083
+ reason: state.accountLimitHit || !state.accountBlock ? void 0 : describeAccountBlock(state.accountBlock)
17084
+ };
17085
+ log.warn("claude account cannot serve this turn; continuing it on the next account automatically", {
17086
+ sessionKey: sk,
17087
+ account: limitAccount,
17088
+ target,
17089
+ cause: state.accountLimitHit ? "usage_limit" : state.accountBlock,
17090
+ resetsAt: state.accountLimitHit?.resetsAt ?? null
17091
+ });
17092
+ state.endTextBlock();
17093
+ state.controllerClosed = true;
17094
+ cleanupTurn();
17095
+ try {
17096
+ controller.close();
17097
+ } catch {
17098
+ }
17099
+ return;
17100
+ }
16730
17101
  if (modelFallbackArmed && attempt && msg.is_error === true) {
16731
17102
  const refusal = state.modelRefusal ?? (state.accountLimitHit && !failoverAskActive && !state.accountBlock ? { kind: "account_limit" } : null);
16732
17103
  if (refusal) {
@@ -16755,19 +17126,20 @@ var ClaudeCodeLanguageModel = class {
16755
17126
  }
16756
17127
  }
16757
17128
  if (state.usageLimitNote) {
17129
+ const noteCandidates = accountRun ? [] : failoverAccounts;
16758
17130
  log.warn("claude account is out of usage; the turn ends with a note", {
16759
17131
  sessionKey: sk,
16760
- account: sourceAccount,
17132
+ account: limitAccount,
16761
17133
  window: state.accountLimitHit?.window ?? null,
16762
17134
  resetsAt: state.accountLimitHit?.resetsAt ?? null,
16763
- candidates: failoverAccounts
17135
+ candidates: noteCandidates
16764
17136
  });
16765
17137
  controller.enqueue({
16766
17138
  type: "text-delta",
16767
17139
  id: state.startTextBlock(),
16768
17140
  delta: formatUsageLimitNote({
16769
- sourceAccount,
16770
- candidates: failoverAccounts,
17141
+ sourceAccount: limitAccount,
17142
+ candidates: noteCandidates,
16771
17143
  resetsAt: state.accountLimitHit?.resetsAt,
16772
17144
  window: state.accountLimitHit?.window,
16773
17145
  resetsText: state.accountLimitHit?.resetsText
@@ -16776,6 +17148,9 @@ var ClaudeCodeLanguageModel = class {
16776
17148
  state.endTextBlock();
16777
17149
  state.resultFailure ??= "usage_limit";
16778
17150
  }
17151
+ if (accountRun && state.accountLimitHit && msg.is_error === true) {
17152
+ state.resultFailure ??= "usage_limit";
17153
+ }
16779
17154
  if (runAutoContinue(state, msg)) return;
16780
17155
  for (const [idx, reasoningId] of state.reasoningIds) {
16781
17156
  if (state.reasoningStarted.get(idx)) {
@@ -16860,10 +17235,14 @@ var ClaudeCodeLanguageModel = class {
16860
17235
  fastMode,
16861
17236
  planModeQuestionActive,
16862
17237
  interactive: useInteractive,
16863
- sourceAccount,
17238
+ sourceAccount: limitAccount,
17239
+ accountConfigDir: accountRun ? self.skillBridgeSpawn(failover).configDir : self.config.configDir,
16864
17240
  failoverAskActive,
17241
+ autoFailoverTarget,
16865
17242
  usageLimitNoteActive,
16866
- failoverAccounts,
17243
+ // Read only when no switch takes the turn, which under `"auto"`
17244
+ // means no account is left to offer.
17245
+ failoverAccounts: accountRun ? [] : failoverAccounts,
16867
17246
  modelFallbackArmed,
16868
17247
  attempt,
16869
17248
  handleControlRequest: handleControlRequest2,
@@ -17506,7 +17885,6 @@ function registerOpencodeLogSink(client) {
17506
17885
  }
17507
17886
 
17508
17887
  // src/v2-client.ts
17509
- import path14 from "path";
17510
17888
  function formatAgentTypeList(agents) {
17511
17889
  const lines = agents.flatMap((agent) => {
17512
17890
  if (agent.hidden === true || agent.mode === "primary") return [];
@@ -17534,8 +17912,7 @@ function describe(value) {
17534
17912
  function toV1Session(info) {
17535
17913
  if (!info || typeof info !== "object") return void 0;
17536
17914
  const session = info;
17537
- const root = session.location?.directory;
17538
- const directory = typeof root === "string" ? typeof session.subpath === "string" && session.subpath.length > 0 ? path14.resolve(root, session.subpath) : root : void 0;
17915
+ const directory = typeof session.location?.directory === "string" ? session.location.directory : void 0;
17539
17916
  return { ...info, directory, parentID: session.parentID };
17540
17917
  }
17541
17918
  function toV1Messages(messages) {