@khalilgharbaoui/opencode-claude-code-plugin 0.54.2 → 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.
@@ -6391,6 +6410,79 @@ function tailIsReplyOnly(history, from) {
6391
6410
  return true;
6392
6411
  }
6393
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
+
6394
6486
  // src/session-resume-store.ts
6395
6487
  import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
6396
6488
  import { homedir as homedir6 } from "os";
@@ -7321,6 +7413,7 @@ function deleteClaudeSessionId(key) {
7321
7413
  if (claudeSessionId) clearLedger(claudeSessionId);
7322
7414
  claudeSessions.delete(key);
7323
7415
  forgetForkFingerprint(key);
7416
+ forgetServedConversation(key);
7324
7417
  forgetResumePoint(key);
7325
7418
  }
7326
7419
  function transferClaudeSession(fromKey, toKey) {
@@ -7332,6 +7425,7 @@ function transferClaudeSession(fromKey, toKey) {
7332
7425
  setClaudeSessionId(toKey, claudeSessionId);
7333
7426
  moveResumePoint(fromKey, toKey);
7334
7427
  moveForkFingerprint(fromKey, toKey);
7428
+ moveServedConversation(fromKey, toKey);
7335
7429
  clearExitPlanModeQuestions(fromKey);
7336
7430
  clearAccountFailoverQuestions(fromKey);
7337
7431
  clearSubagentDispatchQuestions(fromKey);
@@ -7975,12 +8069,14 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
7975
8069
  "agent prompt cache ttl refused: unknown value",
7976
8070
  "auto-continuation stopped",
7977
8071
  "auto-continuing incomplete claude result",
8072
+ "automatic account switch skipped an account whose runtime could not be prepared",
7978
8073
  "btw: a turn is running but no claude process appeared for it",
7979
8074
  "btw: early aside failed; the message will ask again",
7980
8075
  "cannot interrupt this transport; waiting for the turn to end",
7981
8076
  "carried this conversation's claude transcript to the other account",
7982
8077
  "carrying the claude conversation over to the new session key",
7983
8078
  "claude --version output unparseable",
8079
+ "claude account cannot serve this turn; continuing it on the next account automatically",
7984
8080
  "claude account is out of usage; the turn ends with a note",
7985
8081
  "claude already registers a different skill under this name; not bridging ours",
7986
8082
  "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
@@ -8054,6 +8150,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8054
8150
  "interactiveBypass is ignored: skipPermissions governs the interactive transport too",
8055
8151
  "malformed data URI, skipping file part",
8056
8152
  "no active process to respawn (start watchdog); ending turn",
8153
+ "no configured account could take this turn; it ends here",
8057
8154
  "no proxyTools entry was recognised; nothing will be proxied this turn",
8058
8155
  "no stdout after envelope write; respawning claude process to resume conversation",
8059
8156
  "opencode 2 event subscription ended; deleted sessions keep their claude process until idle eviction",
@@ -8083,6 +8180,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8083
8180
  "remote URL images are not supported by Claude CLI, skipping",
8084
8181
  "replaying stdout the child emitted between turns",
8085
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",
8086
8184
  "replaying this conversation as text on the other account",
8087
8185
  "replaying this conversation rather than carrying it across accounts",
8088
8186
  "result fallback timer fired \u2014 closing stream without result event",
@@ -8534,8 +8632,8 @@ function redactLogLine(raw, context) {
8534
8632
  if (!parsed.data || Object.keys(parsed.data).length === 0) return head;
8535
8633
  return `${head} ${JSON.stringify(sanitizeLogData(parsed.data, context))}`;
8536
8634
  }
8537
- function readLogTail(path15, maxBytes = BUNDLE_TAIL_BYTES) {
8538
- const fd = openSync(path15, "r");
8635
+ function readLogTail(path14, maxBytes = BUNDLE_TAIL_BYTES) {
8636
+ const fd = openSync(path14, "r");
8539
8637
  try {
8540
8638
  const size = fstatSync(fd).size;
8541
8639
  const length = Math.min(size, maxBytes);
@@ -9837,8 +9935,10 @@ function clipWithMarker(text, max) {
9837
9935
  \u2026[truncated ${text.length - max} chars]`;
9838
9936
  }
9839
9937
  var MAX_REPLAY_MESSAGE_CHARS = 2e3;
9840
- var MAX_REPLAY_LAST_REPLY_CHARS = 12e3;
9841
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;
9842
9942
  function clipMiddle(text, max) {
9843
9943
  if (text.length <= max) return text;
9844
9944
  const head = Math.min(Math.floor(max / 4), MAX_REPLAY_HEAD_CHARS);
@@ -9902,39 +10002,58 @@ ${clipWithMarker(
9902
10002
  }
9903
10003
  return { text: lines.join("\n"), toolResultCount };
9904
10004
  }
10005
+ function isConversationMessage(msg) {
10006
+ return msg.role === "user" || msg.role === "assistant" || msg.role === "tool";
10007
+ }
9905
10008
  function compactConversationHistory(prompt, opts = {}) {
9906
10009
  const mode = opts.mode ?? "fresh-session";
9907
10010
  prompt = filterSideQuestionHistory(prompt);
9908
10011
  if (mode === "compaction") {
9909
10012
  return buildCompactionHistory(prompt);
9910
10013
  }
9911
- const conversationMessages = prompt.filter(
9912
- (m) => m.role === "user" || m.role === "assistant" || m.role === "tool"
9913
- );
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);
9914
10019
  if (conversationMessages.length <= 1) {
9915
10020
  return null;
9916
10021
  }
9917
- const historyParts = [];
9918
- let lastAssistant = -1;
9919
- for (let i = conversationMessages.length - 2; i >= 0; i--) {
9920
- if (conversationMessages[i].role === "assistant") {
9921
- lastAssistant = i;
9922
- break;
9923
- }
9924
- }
9925
- for (let i = 0; i < conversationMessages.length - 1; i++) {
9926
- 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];
9927
10031
  const role = msg.role === "user" ? "User" : msg.role === "assistant" ? "Assistant" : "Tool";
9928
10032
  const { text } = renderMessageContentForCompaction(msg);
9929
- if (text.trim()) {
9930
- const max = i === lastAssistant ? MAX_REPLAY_LAST_REPLY_CHARS : MAX_REPLAY_MESSAGE_CHARS;
9931
- 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;
9932
10040
  }
10041
+ entries.push(entry);
10042
+ total += entry.length + 2;
9933
10043
  }
9934
- if (historyParts.length === 0) {
9935
- 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
+ );
9936
10055
  }
9937
- return historyParts.join("\n\n");
10056
+ return entries.join("\n\n");
9938
10057
  }
9939
10058
  function buildCompactionHistory(prompt) {
9940
10059
  const entries = [];
@@ -10038,6 +10157,27 @@ ${historyContext}
10038
10157
 
10039
10158
  Now continuing with the current message:
10040
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
+
10041
10181
  `
10042
10182
  });
10043
10183
  }
@@ -12313,9 +12453,9 @@ import { readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "
12313
12453
  import { homedir as homedir11 } from "os";
12314
12454
  import { randomUUID as randomUUID7 } from "crypto";
12315
12455
  import { basename as basename2, dirname as dirname6, isAbsolute as isAbsolute3, join as join15 } from "path";
12316
- function readPromptFileIfPresent(path15) {
12456
+ function readPromptFileIfPresent(path14) {
12317
12457
  try {
12318
- const content = readFileSync9(path15, "utf8").trim();
12458
+ const content = readFileSync9(path14, "utf8").trim();
12319
12459
  return content || void 0;
12320
12460
  } catch {
12321
12461
  return void 0;
@@ -12433,9 +12573,9 @@ function extractSystemMessages(prompt) {
12433
12573
  }
12434
12574
  var CLAUDE_NATIVE_INSTRUCTION_FILES = /* @__PURE__ */ new Set(["CLAUDE.md", "CLAUDE.local.md"]);
12435
12575
  function userAuthoredInstructions(forwarded, opts = {}) {
12436
- const readFile3 = opts.readFile ?? ((path15) => {
12576
+ const readFile3 = opts.readFile ?? ((path14) => {
12437
12577
  try {
12438
- return readFileSync9(path15, "utf8");
12578
+ return readFileSync9(path14, "utf8");
12439
12579
  } catch {
12440
12580
  return null;
12441
12581
  }
@@ -12447,16 +12587,16 @@ function userAuthoredInstructions(forwarded, opts = {}) {
12447
12587
  }
12448
12588
  const seen = /* @__PURE__ */ new Set();
12449
12589
  for (const match of forwarded.matchAll(/^Instructions from: (.+)$/gm)) {
12450
- const path15 = match[1].trim();
12451
- if (seen.has(path15) || !isAbsolute3(path15)) continue;
12452
- if (CLAUDE_NATIVE_INSTRUCTION_FILES.has(basename2(path15))) continue;
12453
- seen.add(path15);
12454
- 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);
12455
12595
  if (!file?.trim()) continue;
12456
12596
  const start = match.index + match[0].length + 1;
12457
12597
  const content = forwarded.startsWith(file, start) ? file : forwarded.startsWith(file.trimEnd(), start) ? file.trimEnd() : null;
12458
12598
  if (content === null) continue;
12459
- out.push({ text: `Instructions from: ${path15}
12599
+ out.push({ text: `Instructions from: ${path14}
12460
12600
  ${content}`, content });
12461
12601
  }
12462
12602
  return out;
@@ -12493,10 +12633,10 @@ ${options.compressionSummary.trim()}`
12493
12633
  if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
12494
12634
  const content = parts.join("\n\n");
12495
12635
  if (!content) return void 0;
12496
- const path15 = join15(pluginTmpDir(), `opencode-cc-sys-${randomUUID7()}.md`);
12636
+ const path14 = join15(pluginTmpDir(), `opencode-cc-sys-${randomUUID7()}.md`);
12497
12637
  try {
12498
- writeFileSync8(path15, content, { encoding: "utf8", mode: 384 });
12499
- return path15;
12638
+ writeFileSync8(path14, content, { encoding: "utf8", mode: 384 });
12639
+ return path14;
12500
12640
  } catch (err) {
12501
12641
  log.warn("failed to write system prompt file", { error: String(err) });
12502
12642
  return void 0;
@@ -14514,6 +14654,7 @@ ${plan}
14514
14654
  resultText: msg.message.content.filter((b) => b.type === "text" && b.text).map((b) => String(b.text)).join("\n")
14515
14655
  }));
14516
14656
  const limitErrorReply = limitFromReply && ctx.usageLimitNoteActive;
14657
+ const autoFailoverReply = (limitFromReply || accountBlockKind(msg) !== null) && ctx.autoFailoverTarget() !== void 0;
14517
14658
  const compactionApiError = ctx.compactionMode && (apiErrorReply || typeof msg.error === "string");
14518
14659
  if (limitFromReply) {
14519
14660
  state.accountLimitHit ??= recallAccountLimit(ctx.sourceAccount);
@@ -14535,7 +14676,7 @@ ${plan}
14535
14676
  }
14536
14677
  for (const block2 of msg.message.content) {
14537
14678
  if (block2.type === "text" && block2.text) {
14538
- if (limitErrorReply) continue;
14679
+ if (limitErrorReply || autoFailoverReply) continue;
14539
14680
  if (compactionApiError) {
14540
14681
  state.compactionFailureText ??= block2.text;
14541
14682
  continue;
@@ -14763,9 +14904,11 @@ ${plan}
14763
14904
  })) {
14764
14905
  state.accountLimitHit = recallAccountLimit(ctx.sourceAccount);
14765
14906
  }
14907
+ const autoFailoverResult = msg.is_error === true && (!!state.accountLimitHit || !!state.accountBlock) && ctx.autoFailoverTarget() !== void 0;
14766
14908
  state.usageLimitNote = ctx.usageLimitNoteActive && !!state.accountLimitHit && // An expired login or a billing hold writes its own note just below
14767
14909
  // and says what to run; two notes about one failure is one too many.
14768
- !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;
14769
14912
  const compactionFailure = ctx.compactionMode && (msg.is_error === true || typeof msg.subtype === "string" && msg.subtype !== "success");
14770
14913
  if (compactionFailure) {
14771
14914
  if (typeof msg.result === "string" && msg.result.trim()) {
@@ -14774,7 +14917,7 @@ ${plan}
14774
14917
  state.resultFailure ??= state.accountBlock ?? (state.accountLimitHit ? "usage_limit" : void 0) ?? (typeof msg.subtype === "string" && msg.subtype !== "success" ? msg.subtype : "error");
14775
14918
  }
14776
14919
  if (!state.currentTextId && msg.is_error && !compactionFailure && // Already on screen from the reply frame, word for word.
14777
- !(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) {
14778
14921
  const errId = state.startTextBlock();
14779
14922
  state.controller.enqueue({
14780
14923
  type: "text-delta",
@@ -14788,14 +14931,14 @@ ${plan}
14788
14931
  if (state.accountBlock && msg.is_error) {
14789
14932
  state.resultFailure ??= state.accountBlock;
14790
14933
  const offeringSwitch = ctx.failoverAskActive;
14791
- if (!compactionFailure) {
14934
+ if (!compactionFailure && !autoFailoverResult) {
14792
14935
  state.controller.enqueue({
14793
14936
  type: "text-delta",
14794
14937
  id: state.startTextBlock(),
14795
14938
  delta: formatAccountBlockNote({
14796
14939
  kind: state.accountBlock,
14797
14940
  account: ctx.sourceAccount,
14798
- configDir: ctx.config.configDir,
14941
+ configDir: ctx.accountConfigDir,
14799
14942
  offeringSwitch,
14800
14943
  // Read only when the form is not taking the turn, which is the
14801
14944
  // default: moving the work by hand is then the operator's only
@@ -14814,7 +14957,7 @@ ${plan}
14814
14957
  const failure = describeResultFailure(msg);
14815
14958
  if (failure) {
14816
14959
  state.resultFailure = msg.subtype;
14817
- if (!compactionFailure) {
14960
+ if (!compactionFailure && !autoFailoverResult) {
14818
14961
  state.controller.enqueue({
14819
14962
  type: "text-delta",
14820
14963
  id: state.startTextBlock(),
@@ -15312,12 +15455,93 @@ var ClaudeCodeLanguageModel = class {
15312
15455
  * because which method opencode used is not in them.
15313
15456
  */
15314
15457
  async streamTurn(options, mode) {
15315
- const result = await this.runModelChain(options, mode);
15458
+ const result = await this.runAccountFailover(options, mode);
15316
15459
  return {
15317
15460
  ...result,
15318
15461
  stream: translateStreamForHost(result.stream, this.config.hostApi ?? "v1")
15319
15462
  };
15320
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
+ }
15321
15545
  /**
15322
15546
  * Run the turn, moving to the next model in the fallback chain if the one
15323
15547
  * it started on is refused. The single wiring point for `src/model-fallback.ts`.
@@ -15336,14 +15560,14 @@ var ClaudeCodeLanguageModel = class {
15336
15560
  * and an exhausted chain leaves the last attempt un-armed so its error
15337
15561
  * surfaces exactly as it does today.
15338
15562
  */
15339
- async runModelChain(options, mode) {
15563
+ async runModelChain(options, mode, accountRun) {
15340
15564
  const agent = this.getOpencodeAgent(options);
15341
15565
  const chain = this.isCompactionCall(options) ? [] : resolveFallbackChain(agent, resolveAgentModel(agent, this.modelId));
15342
- if (chain.length === 0) return this.doStreamForHost(options, void 0, mode);
15566
+ if (chain.length === 0) return this.doStreamForHost(options, void 0, mode, accountRun);
15343
15567
  const self = this;
15344
15568
  const tried = /* @__PURE__ */ new Set();
15345
15569
  let attempt = { armed: true };
15346
- let inner = await this.doStreamForHost(options, attempt, mode);
15570
+ let inner = await this.doStreamForHost(options, attempt, mode, accountRun);
15347
15571
  const request = inner.request;
15348
15572
  const stream = new ReadableStream({
15349
15573
  async start(controller) {
@@ -15413,7 +15637,7 @@ var ClaudeCodeLanguageModel = class {
15413
15637
  modelOverride: next,
15414
15638
  armed: nextFallbackModel(chain, tried) !== void 0
15415
15639
  };
15416
- inner = await self.doStreamForHost(options, attempt, mode);
15640
+ inner = await self.doStreamForHost(options, attempt, mode, accountRun);
15417
15641
  }
15418
15642
  } catch (error) {
15419
15643
  controller.enqueue({ type: "error", error });
@@ -15427,8 +15651,10 @@ var ClaudeCodeLanguageModel = class {
15427
15651
  });
15428
15652
  return { stream, request };
15429
15653
  }
15430
- async doStreamForHost(options, attempt, mode = "stream") {
15654
+ async doStreamForHost(options, attempt, mode = "stream", accountRun) {
15431
15655
  const warnings = [];
15656
+ const autoSwitch = accountRun?.switchTo;
15657
+ if (accountRun) accountRun.switchTo = void 0;
15432
15658
  const turnAbort = watchTurnAbort(options.abortSignal);
15433
15659
  const abortedBeforeWork = (stage) => {
15434
15660
  log.info("abort before the turn asked claude for work; nothing spawned", {
@@ -15668,12 +15894,17 @@ var ClaudeCodeLanguageModel = class {
15668
15894
  });
15669
15895
  }
15670
15896
  const hasPriorConversation = options.prompt.filter((m) => m.role === "user" || m.role === "assistant").length > 1;
15671
- if (!hasPriorConversation) {
15897
+ if (!hasPriorConversation && !autoSwitch) {
15672
15898
  deleteClaudeSessionId(sk);
15673
15899
  deleteActiveProcess(sk);
15674
15900
  clearCompression(sk);
15675
15901
  }
15676
- 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, {
15677
15908
  sourceAccount,
15678
15909
  candidates: failoverCandidates(
15679
15910
  this.config.failoverAccounts,
@@ -15731,13 +15962,84 @@ var ClaudeCodeLanguageModel = class {
15731
15962
  cliPath: this.config.cliPath,
15732
15963
  modelId: effectiveModelId
15733
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
+ }
15734
16034
  cliPath = failover.cliPath;
15735
16035
  asideTransportRef.cliPath = cliPath;
15736
- failoverNote = formatFailoverNote({
15737
- sourceAccount: failoverAnswer.sourceAccount,
15738
- target: failoverAnswer.target,
15739
- resetsAt: failoverAnswer.resetsAt
15740
- });
16036
+ if (!autoSwitch) {
16037
+ failoverNote = formatFailoverNote({
16038
+ sourceAccount: failoverAnswer.sourceAccount,
16039
+ target: failoverAnswer.target,
16040
+ resetsAt: failoverAnswer.resetsAt
16041
+ });
16042
+ }
15741
16043
  }
15742
16044
  const spawnConfigDir = this.config.accountInProcess ? this.skillBridgeSpawn(failover).configDir : void 0;
15743
16045
  const processForAccount = getActiveProcess(sk);
@@ -15801,6 +16103,15 @@ var ClaudeCodeLanguageModel = class {
15801
16103
  if (failoverAnswer?.kind === "switch" && hasPriorConversation && !conversationOnTarget) {
15802
16104
  includeHistoryContext = true;
15803
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
+ }
15804
16115
  let accountGroupNote = null;
15805
16116
  if (!compactionMode && groups && accountProviders.size > 0 && includeHistoryContext && failoverAnswer?.kind !== "switch") {
15806
16117
  const foreign = findForeignAccountSibling({
@@ -15973,6 +16284,14 @@ var ClaudeCodeLanguageModel = class {
15973
16284
  messages: options.prompt.length
15974
16285
  });
15975
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
+ }
15976
16295
  if (this.config.forkSessions === true && !compactionMode) {
15977
16296
  recordForkFingerprint(sk, options.prompt, cliPath);
15978
16297
  }
@@ -15993,7 +16312,8 @@ var ClaudeCodeLanguageModel = class {
15993
16312
  compactionMode,
15994
16313
  compactionInstructions: useInteractive && compactionMode ? extractSystemMessages(effectivePrompt).join("\n\n") : void 0,
15995
16314
  cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)),
15996
- stripContextReminders: this.stripContextRemindersEnabled()
16315
+ stripContextReminders: this.stripContextRemindersEnabled(),
16316
+ missed: missed?.messages
15997
16317
  });
15998
16318
  const ptyPlanMode = useInteractive && !compactionMode && this.config.permissionMode === "plan";
15999
16319
  const resolvedProxy = compactionMode ? null : ptyPlanMode ? this.resolvedProxyTools()?.filter((def) => def.name !== "write" && def.name !== "edit") ?? null : this.resolvedProxyTools();
@@ -16058,6 +16378,16 @@ var ClaudeCodeLanguageModel = class {
16058
16378
  // the operator is usually not even looking at.
16059
16379
  childSession: !!await fetchSessionParentId(affinity)
16060
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;
16061
16391
  const usageLimitNoteActive = !compactionMode && mode !== "generate" && !failoverAskActive;
16062
16392
  let staleBuild = null;
16063
16393
  if (!compactionMode && mode !== "generate" && affinity !== "default") {
@@ -16698,6 +17028,9 @@ var ClaudeCodeLanguageModel = class {
16698
17028
  account: failover.target ?? sourceAccount
16699
17029
  });
16700
17030
  }
17031
+ if (!compactionMode) {
17032
+ recordServedConversation(sk, filterSideQuestionHistory(options.prompt));
17033
+ }
16701
17034
  if (deliverPendingCompletions(state)) {
16702
17035
  if (state.drainBuffer.length > 0) drainNow(state);
16703
17036
  return;
@@ -16735,6 +17068,36 @@ var ClaudeCodeLanguageModel = class {
16735
17068
  finishWithQuestionCall(state, call);
16736
17069
  return;
16737
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
+ }
16738
17101
  if (modelFallbackArmed && attempt && msg.is_error === true) {
16739
17102
  const refusal = state.modelRefusal ?? (state.accountLimitHit && !failoverAskActive && !state.accountBlock ? { kind: "account_limit" } : null);
16740
17103
  if (refusal) {
@@ -16763,19 +17126,20 @@ var ClaudeCodeLanguageModel = class {
16763
17126
  }
16764
17127
  }
16765
17128
  if (state.usageLimitNote) {
17129
+ const noteCandidates = accountRun ? [] : failoverAccounts;
16766
17130
  log.warn("claude account is out of usage; the turn ends with a note", {
16767
17131
  sessionKey: sk,
16768
- account: sourceAccount,
17132
+ account: limitAccount,
16769
17133
  window: state.accountLimitHit?.window ?? null,
16770
17134
  resetsAt: state.accountLimitHit?.resetsAt ?? null,
16771
- candidates: failoverAccounts
17135
+ candidates: noteCandidates
16772
17136
  });
16773
17137
  controller.enqueue({
16774
17138
  type: "text-delta",
16775
17139
  id: state.startTextBlock(),
16776
17140
  delta: formatUsageLimitNote({
16777
- sourceAccount,
16778
- candidates: failoverAccounts,
17141
+ sourceAccount: limitAccount,
17142
+ candidates: noteCandidates,
16779
17143
  resetsAt: state.accountLimitHit?.resetsAt,
16780
17144
  window: state.accountLimitHit?.window,
16781
17145
  resetsText: state.accountLimitHit?.resetsText
@@ -16784,6 +17148,9 @@ var ClaudeCodeLanguageModel = class {
16784
17148
  state.endTextBlock();
16785
17149
  state.resultFailure ??= "usage_limit";
16786
17150
  }
17151
+ if (accountRun && state.accountLimitHit && msg.is_error === true) {
17152
+ state.resultFailure ??= "usage_limit";
17153
+ }
16787
17154
  if (runAutoContinue(state, msg)) return;
16788
17155
  for (const [idx, reasoningId] of state.reasoningIds) {
16789
17156
  if (state.reasoningStarted.get(idx)) {
@@ -16868,10 +17235,14 @@ var ClaudeCodeLanguageModel = class {
16868
17235
  fastMode,
16869
17236
  planModeQuestionActive,
16870
17237
  interactive: useInteractive,
16871
- sourceAccount,
17238
+ sourceAccount: limitAccount,
17239
+ accountConfigDir: accountRun ? self.skillBridgeSpawn(failover).configDir : self.config.configDir,
16872
17240
  failoverAskActive,
17241
+ autoFailoverTarget,
16873
17242
  usageLimitNoteActive,
16874
- 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,
16875
17246
  modelFallbackArmed,
16876
17247
  attempt,
16877
17248
  handleControlRequest: handleControlRequest2,
@@ -17514,7 +17885,6 @@ function registerOpencodeLogSink(client) {
17514
17885
  }
17515
17886
 
17516
17887
  // src/v2-client.ts
17517
- import path14 from "path";
17518
17888
  function formatAgentTypeList(agents) {
17519
17889
  const lines = agents.flatMap((agent) => {
17520
17890
  if (agent.hidden === true || agent.mode === "primary") return [];
@@ -17542,8 +17912,7 @@ function describe(value) {
17542
17912
  function toV1Session(info) {
17543
17913
  if (!info || typeof info !== "object") return void 0;
17544
17914
  const session = info;
17545
- const root = session.location?.directory;
17546
- 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;
17547
17916
  return { ...info, directory, parentID: session.parentID };
17548
17917
  }
17549
17918
  function toV1Messages(messages) {