@khalilgharbaoui/opencode-claude-code-plugin 0.37.0 → 0.38.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/README.md CHANGED
@@ -55,7 +55,7 @@ Nothing in the picker? [Troubleshooting](./docs/troubleshooting/symptoms.md) is
55
55
  |---|---|
56
56
  | **Selective tool proxy** | Choose, per tool, whether Claude Code or opencode executes it. Proxied calls end on an event (the result, an abort, the next message, the child exiting), never on a clock. [Guide](./docs/guides/tool-proxy.md) |
57
57
  | **Claude's own tools, MCP and skills** | `Read`, `Grep`, `Glob`, `WebSearch` run in Claude Code; your opencode MCP servers are bridged in; your opencode skills can be staged for Claude's `Skill` tool. [MCP](./docs/configuration/mcp.md) · [Skills](./docs/configuration/skills.md) |
58
- | **Several accounts, with failover** | `"accounts": ["personal", "work"]` becomes one provider per account. Out of usage mid-task? opencode's question form offers the others, and nothing moves until you pick. [Accounts](./docs/configuration/accounts.md) |
58
+ | **Several accounts** | `"accounts": ["personal", "work"]` becomes one provider per account. Out of usage mid-task? One line says which account, which window and when it resets in your own time zone, and names the others to pick a model from. [Accounts](./docs/configuration/accounts.md) |
59
59
  | **Subagents: your account, their model** | `forceModel`, `reasoningEffort` and `cacheTtl` in an agent file, inheriting the caller's account. `task_batch` runs several subagents at once. [Subagents](./docs/configuration/subagents.md) |
60
60
  | **18 models, reasoning variants, fast mode** | Haiku 4.5 through Opus 5.5, Fable and Mythos, each with a `(N×)` list-price suffix, `low` to `max` effort variants, and a fallback chain for a model this account cannot run today. [Models](./docs/models.md) |
61
61
  | **`/btw` and `/claude-code-doctor`** | Side questions on the live process, and a health report whose `bundle` form is redacted by allowlist so it is safe to paste into a public issue. [`/btw`](./docs/guides/btw.md) · [Doctor](./docs/guides/doctor.md) |
package/dist/index.js CHANGED
@@ -149,6 +149,7 @@ import path from "path";
149
149
  import { appendFileSync, mkdirSync, renameSync, statSync } from "fs";
150
150
  import { homedir } from "os";
151
151
  import { dirname, join } from "path";
152
+ import { isMainThread } from "worker_threads";
152
153
  var LEVEL_RANK = {
153
154
  debug: 0,
154
155
  info: 1,
@@ -241,15 +242,110 @@ function fmt(level, msg, data) {
241
242
  function shouldEmit(level) {
242
243
  return LEVEL_RANK[level] >= LEVEL_RANK[activeConfig.level];
243
244
  }
244
- function shouldTui(level) {
245
+ var tuiHostOverride;
246
+ function isTuiHost() {
247
+ return tuiHostOverride ?? !isMainThread;
248
+ }
249
+ var MAX_BUFFERED_SINK_ENTRIES = 50;
250
+ var MAX_TOASTED_MESSAGES = 200;
251
+ var toastBurstLimit = 3;
252
+ var toastBurstWindowMs = 2e3;
253
+ var activeSink = null;
254
+ var bufferedEntries = [];
255
+ var droppedBeforeSink = 0;
256
+ var toastedMessages = /* @__PURE__ */ new Set();
257
+ var burstWindowStart = 0;
258
+ var burstCount = 0;
259
+ var burstSuppressed = 0;
260
+ var burstSummaryTimer = null;
261
+ function setLogSink(sink) {
262
+ activeSink = sink;
263
+ if (!sink) return;
264
+ const pending = bufferedEntries;
265
+ const dropped = droppedBeforeSink;
266
+ bufferedEntries = [];
267
+ droppedBeforeSink = 0;
268
+ for (const entry of pending) deliverToSink(entry);
269
+ if (dropped > 0) {
270
+ const message = `${dropped} earlier log line(s) were dropped before opencode's log channel was available`;
271
+ deliverToSink({
272
+ level: "warn",
273
+ message,
274
+ line: fmt("WARN", message)
275
+ });
276
+ }
277
+ }
278
+ function routeToSink(entry) {
279
+ if (!activeSink) {
280
+ if (bufferedEntries.length >= MAX_BUFFERED_SINK_ENTRIES) {
281
+ droppedBeforeSink += 1;
282
+ return;
283
+ }
284
+ bufferedEntries.push(entry);
285
+ return;
286
+ }
287
+ deliverToSink(entry);
288
+ }
289
+ function deliverToSink(entry) {
290
+ const sink = activeSink;
291
+ if (!sink) return;
292
+ try {
293
+ sink.log(entry);
294
+ } catch {
295
+ }
296
+ if (entry.level !== "warn" && entry.level !== "error") return;
297
+ if (toastedMessages.has(entry.message)) return;
298
+ if (toastedMessages.size < MAX_TOASTED_MESSAGES) toastedMessages.add(entry.message);
299
+ if (!allowToastNow()) return;
300
+ sendToast(sink, entry);
301
+ }
302
+ function sendToast(sink, entry) {
303
+ try {
304
+ sink.toast(entry);
305
+ } catch {
306
+ }
307
+ }
308
+ function allowToastNow() {
309
+ const now = Date.now();
310
+ if (now - burstWindowStart >= toastBurstWindowMs) {
311
+ burstWindowStart = now;
312
+ burstCount = 0;
313
+ }
314
+ if (burstCount < toastBurstLimit) {
315
+ burstCount += 1;
316
+ return true;
317
+ }
318
+ burstSuppressed += 1;
319
+ armBurstSummary();
320
+ return false;
321
+ }
322
+ function armBurstSummary() {
323
+ if (burstSummaryTimer) return;
324
+ burstSummaryTimer = setTimeout(() => {
325
+ burstSummaryTimer = null;
326
+ const suppressed = burstSuppressed;
327
+ burstSuppressed = 0;
328
+ if (suppressed <= 0) return;
329
+ const sink = activeSink;
330
+ if (!sink) return;
331
+ const message = `${suppressed} more claude-code warning(s) were not shown; the full text is in opencode's log`;
332
+ sendToast(sink, { level: "warn", message, line: fmt("WARN", message) });
333
+ }, toastBurstWindowMs);
334
+ burstSummaryTimer.unref?.();
335
+ }
336
+ function shouldSurface(level) {
245
337
  if (level === "warn" || level === "error") return true;
246
338
  return activeConfig.mode === "debug";
247
339
  }
248
340
  function emit(level, msg, data) {
249
341
  if (!shouldEmit(level)) return;
250
342
  const line = fmt(level.toUpperCase(), msg, data);
251
- if (shouldTui(level)) {
252
- console.error(line);
343
+ if (shouldSurface(level)) {
344
+ if (isTuiHost()) {
345
+ routeToSink({ level, message: msg, data, line });
346
+ } else {
347
+ console.error(line);
348
+ }
253
349
  }
254
350
  writeToFile(line);
255
351
  }
@@ -488,6 +584,20 @@ function formatResetsAt(resetsAt) {
488
584
  const date = new Date(ms);
489
585
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
490
586
  }
587
+ function formatLocalMinute(ms) {
588
+ const when = new Date(ms);
589
+ const pad = (value) => String(value).padStart(2, "0");
590
+ return `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())} ${pad(when.getHours())}:${pad(when.getMinutes())}`;
591
+ }
592
+ function formatResetsAtLocal(resetsAt) {
593
+ const ms = resetsAtToMs(resetsAt);
594
+ if (ms === void 0 || Number.isNaN(new Date(ms).getTime())) return void 0;
595
+ return formatLocalMinute(ms);
596
+ }
597
+ function describeRateLimitWindow(rateLimitType) {
598
+ if (!rateLimitType) return void 0;
599
+ return RATE_LIMIT_WINDOWS[rateLimitType] ?? rateLimitType;
600
+ }
491
601
  function rateLimitKey(info) {
492
602
  return [
493
603
  info.status ?? "?",
@@ -503,7 +613,7 @@ function isRateLimitRejected(info) {
503
613
  }
504
614
  function describeRateLimit(info) {
505
615
  if (!info.status && !info.overageStatus) return null;
506
- const window = info.rateLimitType ? RATE_LIMIT_WINDOWS[info.rateLimitType] ?? info.rateLimitType : void 0;
616
+ const window = describeRateLimitWindow(info.rateLimitType);
507
617
  const resets = formatResetsAt(info.resetsAt);
508
618
  const overageResets = formatResetsAt(info.overageResetsAt);
509
619
  const key = rateLimitKey(info);
@@ -518,9 +628,7 @@ function describeRateLimit(info) {
518
628
  if (resetAt) parts.push(`Resets at ${resetAt}.`);
519
629
  parts.push(RATE_LIMIT_ACTION);
520
630
  const message = parts.join(" ");
521
- return { key, level: "warn", message, transcript: `
522
- ${RATE_LIMIT_MARKER} ${message}
523
- ` };
631
+ return { key, level: "warn", message, transcript: null };
524
632
  }
525
633
  if (info.status === "allowed_warning" || info.overageStatus === "allowed_warning") {
526
634
  const used = info.utilization === void 0 ? "" : ` (${Math.round(info.utilization * 100)}% used)`;
@@ -987,6 +1095,7 @@ var PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?";
987
1095
  var OPENCODE_QUESTION_RESULT_PREFIX = "User has answered your questions: ";
988
1096
  var OPENCODE_QUESTION_RESULT_SUFFIX = ". You can now continue with the user's answers in mind.";
989
1097
  var OPENCODE_UNANSWERED = "Unanswered";
1098
+ var DCP_MESSAGE_ID_SUFFIX = /(?:\s*<dcp-message-id>[^<>]*<\/dcp-message-id>)+\s*$/;
990
1099
  var KEY_SEPARATOR = "\0";
991
1100
  function isPlanModeQuestionActive(input) {
992
1101
  if (input.compactionMode) return false;
@@ -1086,15 +1195,16 @@ function unwrapToolOutput(part) {
1086
1195
  }
1087
1196
  }
1088
1197
  function unwrapOpencodeQuestionResult(value, question) {
1089
- const sentence = value.trim();
1198
+ const untagged = value.replace(DCP_MESSAGE_ID_SUFFIX, "");
1199
+ const sentence = untagged.trim();
1090
1200
  if (!sentence.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) || !sentence.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
1091
- return value;
1201
+ return untagged;
1092
1202
  }
1093
1203
  const body = sentence.slice(
1094
1204
  OPENCODE_QUESTION_RESULT_PREFIX.length,
1095
1205
  sentence.length - OPENCODE_QUESTION_RESULT_SUFFIX.length
1096
1206
  );
1097
- if (!body.startsWith('"') || !body.endsWith('"')) return value;
1207
+ if (!body.startsWith('"') || !body.endsWith('"')) return untagged;
1098
1208
  let answer;
1099
1209
  const head = question === void 0 ? void 0 : `"${question}"="`;
1100
1210
  if (head && body.startsWith(head) && body.length > head.length) {
@@ -1103,7 +1213,7 @@ function unwrapOpencodeQuestionResult(value, question) {
1103
1213
  const split = body.lastIndexOf('"="');
1104
1214
  if (split > 0) answer = body.slice(split + 3, -1);
1105
1215
  }
1106
- if (answer === void 0) return value;
1216
+ if (answer === void 0) return untagged;
1107
1217
  return answer === OPENCODE_UNANSWERED ? "" : answer;
1108
1218
  }
1109
1219
  function collectAnswerStrings(value, question) {
@@ -1161,6 +1271,13 @@ var ACCOUNT_LIMIT_PATTERNS = [
1161
1271
  /you[’'`]?ve hit your individual spend limit/i
1162
1272
  ];
1163
1273
  var ACCOUNT_BLOCK_MARKER = "\u258C **claude account:**";
1274
+ var USAGE_LIMIT_MARKER = "\u258C **usage limit:**";
1275
+ function describeOtherAccounts(candidates) {
1276
+ const names = candidates.map((candidate) => normalizeAccountName(candidate)).filter(Boolean).map((candidate) => `"${candidate}"`);
1277
+ if (names.length === 0) return "";
1278
+ const joined = names.length === 1 ? names[0] : `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
1279
+ return `the ${joined} account`;
1280
+ }
1164
1281
  var ACCOUNT_BLOCKS = {
1165
1282
  authentication_failed: { what: "is not logged in (its login expired or was revoked)", login: true },
1166
1283
  oauth_org_not_allowed: { what: "belongs to an organization that does not allow this login", login: false },
@@ -1184,17 +1301,51 @@ function formatAccountBlockNote(input) {
1184
1301
  const block = ACCOUNT_BLOCKS[input.kind];
1185
1302
  const account = normalizeAccountName(input.account || DEFAULT_ACCOUNT);
1186
1303
  const fix = block.login ? `Log in again with \`${loginCommandFor(input.configDir)}\`, then resend your message.` : "Check the account at claude.ai, then resend your message.";
1187
- const then = input.offeringSwitch ? " Or pick another account below." : "";
1304
+ const others = input.offeringSwitch ? "" : describeOtherAccounts(input.candidates ?? []);
1305
+ const then = input.offeringSwitch ? " Or pick another account below." : others ? ` Or pick a model from ${others} and resend.` : "";
1188
1306
  return `
1189
1307
  ${ACCOUNT_BLOCK_MARKER} the Claude account "${account}" ${block.what}. ${fix}${then}
1190
1308
  `;
1191
1309
  }
1310
+ function formatUsageLimitNote(input) {
1311
+ const account = normalizeAccountName(input.sourceAccount || DEFAULT_ACCOUNT);
1312
+ const window = describeRateLimitWindow(input.window);
1313
+ const resets = formatResetsAtLocal(input.resetsAt);
1314
+ const others = describeOtherAccounts(input.candidates);
1315
+ const what = [
1316
+ `the Claude account "${account}" is out of usage`,
1317
+ window ? ` in ${window}` : "",
1318
+ resets ? `, which resets at ${resets}` : "",
1319
+ "."
1320
+ ].join("");
1321
+ const advice = others ? `Pick a model from ${others} and resend your message, or wait for the window to reset.` : "Wait for the window to reset, or enable extra usage on the account.";
1322
+ return `
1323
+ ${USAGE_LIMIT_MARKER} ${what} ${advice}
1324
+ `;
1325
+ }
1192
1326
  function isAccountLimitError(input) {
1193
1327
  if (input.rateLimit && isRateLimitRejected(input.rateLimit)) return true;
1194
1328
  const text = input.resultText;
1195
1329
  if (typeof text !== "string" || text.length === 0) return false;
1196
1330
  return ACCOUNT_LIMIT_PATTERNS.some((pattern) => pattern.test(text));
1197
1331
  }
1332
+ var lastAccountLimits = /* @__PURE__ */ new Map();
1333
+ function rememberAccountLimit(account, facts) {
1334
+ const name = normalizeAccountName(account || DEFAULT_ACCOUNT);
1335
+ if (!name) return;
1336
+ lastAccountLimits.set(name, { resetsAt: facts.resetsAt, window: facts.window });
1337
+ }
1338
+ function recallAccountLimit(account, now = Date.now()) {
1339
+ const name = normalizeAccountName(account || DEFAULT_ACCOUNT);
1340
+ const facts = lastAccountLimits.get(name);
1341
+ if (!facts) return {};
1342
+ const resetsAtMs = resetsAtToMs(facts.resetsAt);
1343
+ const fresh = resetsAtMs !== void 0 && resetsAtMs > now;
1344
+ return {
1345
+ ...fresh ? { resetsAt: facts.resetsAt } : {},
1346
+ ...facts.window === void 0 ? {} : { window: facts.window }
1347
+ };
1348
+ }
1198
1349
  var accountOverrides = /* @__PURE__ */ new Map();
1199
1350
  function setAccountOverride(source, target, until, now = Date.now()) {
1200
1351
  const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
@@ -1262,7 +1413,7 @@ function isAccountFailoverQuestionActive(input) {
1262
1413
  if (input.compactionMode) return false;
1263
1414
  if (input.interactive) return false;
1264
1415
  if (input.childSession) return false;
1265
- if (input.configured === "off") return false;
1416
+ if (input.configured !== "ask") return false;
1266
1417
  if (input.candidates.length === 0) return false;
1267
1418
  return input.opencodeHasQuestion;
1268
1419
  }
@@ -5378,6 +5529,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5378
5529
  "btw: early aside failed; the message will ask again",
5379
5530
  "cannot interrupt this transport; waiting for the turn to end",
5380
5531
  "claude --version output unparseable",
5532
+ "claude account is out of usage; the turn ends with a note",
5381
5533
  "claude already registers a different skill under this name; not bridging ours",
5382
5534
  "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
5383
5535
  "claude cli does not support --plugin-dir; opencode skills will not be bridged. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
@@ -6232,11 +6384,6 @@ function compareBuilds(loaded, onDisk) {
6232
6384
  rebuiltAt: onDisk.mtimeMs
6233
6385
  };
6234
6386
  }
6235
- function formatLocalMinute(ms) {
6236
- const when = new Date(ms);
6237
- const pad = (value) => String(value).padStart(2, "0");
6238
- return `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())} ${pad(when.getHours())}:${pad(when.getMinutes())}`;
6239
- }
6240
6387
  function formatStaleBuildNote(stale) {
6241
6388
  const loadedAt = formatLocalMinute(stale.loadedAt);
6242
6389
  if (stale.kind === "version") {
@@ -7462,6 +7609,7 @@ var PLUGIN_NOTE_MARKERS = [
7462
7609
  SILENT_TURN_MARKER,
7463
7610
  FAILOVER_MARKER,
7464
7611
  ACCOUNT_BLOCK_MARKER,
7612
+ USAGE_LIMIT_MARKER,
7465
7613
  MODEL_FALLBACK_MARKER,
7466
7614
  UNATTENDED_REPLAY_MARKER,
7467
7615
  STALE_BUILD_MARKER
@@ -9872,6 +10020,7 @@ function createTurnState(init) {
9872
10020
  lastCallUsage: void 0,
9873
10021
  resultFailure: void 0,
9874
10022
  accountLimitHit: null,
10023
+ usageLimitNote: false,
9875
10024
  accountBlock: null,
9876
10025
  modelRefusal: null,
9877
10026
  drainBuffer: [],
@@ -10652,6 +10801,7 @@ function createLineHandler(state, ctx) {
10652
10801
  resetsAt: info.resetsAt ?? info.overageResetsAt,
10653
10802
  window: info.rateLimitType
10654
10803
  };
10804
+ rememberAccountLimit(ctx.sourceAccount, state.accountLimitHit);
10655
10805
  }
10656
10806
  const note = reportRateLimitEvent(msg);
10657
10807
  if (note) {
@@ -10935,6 +11085,13 @@ ${plan}
10935
11085
  if (hasText) {
10936
11086
  state.hasReceivedContent = true;
10937
11087
  }
11088
+ const apiErrorReply = msg.is_api_error_message === true;
11089
+ const limitErrorReply = apiErrorReply && ctx.usageLimitNoteActive && !state.accountBlock && (msg.error === "rate_limit" || isAccountLimitError({
11090
+ resultText: msg.message.content.filter((b) => b.type === "text" && b.text).map((b) => String(b.text)).join("\n")
11091
+ }));
11092
+ if (limitErrorReply) {
11093
+ state.accountLimitHit ??= recallAccountLimit(ctx.sourceAccount);
11094
+ }
10938
11095
  if (hasText && !hasToolUse) {
10939
11096
  startResultFallback(state);
10940
11097
  }
@@ -10943,6 +11100,7 @@ ${plan}
10943
11100
  }
10944
11101
  for (const block2 of msg.message.content) {
10945
11102
  if (block2.type === "text" && block2.text) {
11103
+ if (limitErrorReply) continue;
10946
11104
  resetLastVisibleTextBlock(state);
10947
11105
  const blockId = state.startTextBlock();
10948
11106
  state.controller.enqueue({
@@ -11155,7 +11313,15 @@ ${plan}
11155
11313
  if (deliverPendingCompletions(state)) {
11156
11314
  return;
11157
11315
  }
11158
- if (!state.currentTextId && msg.is_error && typeof msg.result === "string" && msg.result.trim().length > 0) {
11316
+ if (!state.accountLimitHit && msg.is_error && isAccountLimitError({
11317
+ resultText: typeof msg.result === "string" ? msg.result : null
11318
+ })) {
11319
+ state.accountLimitHit = recallAccountLimit(ctx.sourceAccount);
11320
+ }
11321
+ state.usageLimitNote = ctx.usageLimitNoteActive && !!state.accountLimitHit && // An expired login or a billing hold writes its own note just below
11322
+ // and says what to run; two notes about one failure is one too many.
11323
+ !state.accountBlock && msg.is_error === true;
11324
+ if (!state.currentTextId && msg.is_error && !state.usageLimitNote && typeof msg.result === "string" && msg.result.trim().length > 0) {
11159
11325
  const errId = state.startTextBlock();
11160
11326
  state.controller.enqueue({
11161
11327
  type: "text-delta",
@@ -11163,11 +11329,6 @@ ${plan}
11163
11329
  delta: msg.result
11164
11330
  });
11165
11331
  }
11166
- if (!state.accountLimitHit && msg.is_error && isAccountLimitError({
11167
- resultText: typeof msg.result === "string" ? msg.result : null
11168
- })) {
11169
- state.accountLimitHit = {};
11170
- }
11171
11332
  if (ctx.modelFallbackArmed && !state.modelRefusal) {
11172
11333
  state.modelRefusal = modelRefusalFromResult(msg);
11173
11334
  }
@@ -11181,7 +11342,11 @@ ${plan}
11181
11342
  kind: state.accountBlock,
11182
11343
  account: ctx.sourceAccount,
11183
11344
  configDir: ctx.config.configDir,
11184
- offeringSwitch
11345
+ offeringSwitch,
11346
+ // Read only when the form is not taking the turn, which is the
11347
+ // default: moving the work by hand is then the operator's only
11348
+ // route and nothing else on screen says it exists (h #g194).
11349
+ candidates: ctx.failoverAccounts
11185
11350
  })
11186
11351
  });
11187
11352
  state.endTextBlock();
@@ -12060,7 +12225,7 @@ var ClaudeCodeLanguageModel = class {
12060
12225
  this.config.failoverAccounts,
12061
12226
  sourceAccount
12062
12227
  );
12063
- const failoverAskActive = failoverAccounts.length > 0 && this.config.accountFailover !== "off" && !compactionMode && !useInteractive && // A `doGenerate` caller takes the override and reports the plain
12228
+ const failoverAskActive = failoverAccounts.length > 0 && this.config.accountFailover === "ask" && !compactionMode && !useInteractive && // A `doGenerate` caller takes the override and reports the plain
12064
12229
  // rate-limit error, never the form. See `TurnMode`.
12065
12230
  mode !== "generate" && isAccountFailoverQuestionActive({
12066
12231
  configured: this.config.accountFailover,
@@ -12073,6 +12238,7 @@ var ClaudeCodeLanguageModel = class {
12073
12238
  // the operator is usually not even looking at.
12074
12239
  childSession: !!await fetchSessionParentId(affinity)
12075
12240
  });
12241
+ const usageLimitNoteActive = !compactionMode && mode !== "generate" && !failoverAskActive;
12076
12242
  let staleBuild = null;
12077
12243
  if (!compactionMode && mode !== "generate" && affinity !== "default") {
12078
12244
  staleBuild = staleBuildWatch().check();
@@ -12620,6 +12786,26 @@ var ClaudeCodeLanguageModel = class {
12620
12786
  return;
12621
12787
  }
12622
12788
  }
12789
+ if (state.usageLimitNote) {
12790
+ log.warn("claude account is out of usage; the turn ends with a note", {
12791
+ sessionKey: sk,
12792
+ account: sourceAccount,
12793
+ window: state.accountLimitHit?.window ?? null,
12794
+ resetsAt: state.accountLimitHit?.resetsAt ?? null,
12795
+ candidates: failoverAccounts
12796
+ });
12797
+ controller.enqueue({
12798
+ type: "text-delta",
12799
+ id: state.startTextBlock(),
12800
+ delta: formatUsageLimitNote({
12801
+ sourceAccount,
12802
+ candidates: failoverAccounts,
12803
+ resetsAt: state.accountLimitHit?.resetsAt,
12804
+ window: state.accountLimitHit?.window
12805
+ })
12806
+ });
12807
+ state.endTextBlock();
12808
+ }
12623
12809
  if (runAutoContinue(state, msg)) return;
12624
12810
  for (const [idx, reasoningId] of state.reasoningIds) {
12625
12811
  if (state.reasoningStarted.get(idx)) {
@@ -12693,6 +12879,8 @@ var ClaudeCodeLanguageModel = class {
12693
12879
  planModeQuestionActive,
12694
12880
  sourceAccount,
12695
12881
  failoverAskActive,
12882
+ usageLimitNoteActive,
12883
+ failoverAccounts,
12696
12884
  modelFallbackArmed,
12697
12885
  attempt,
12698
12886
  handleControlRequest: handleControlRequest2,
@@ -13142,6 +13330,72 @@ function cleanupOne(cacheRoot, ourDir) {
13142
13330
  }
13143
13331
  }
13144
13332
 
13333
+ // src/tui-log-sink.ts
13334
+ var LOG_SERVICE_NAME = "opencode-claude-code";
13335
+ var TOAST_TITLE = "claude-code";
13336
+ var TOAST_DURATION_MS = 1e4;
13337
+ var MAX_TOAST_CHARS = 240;
13338
+ var APP_LOG_LEVEL = {
13339
+ debug: "debug",
13340
+ info: "info",
13341
+ notice: "info",
13342
+ warn: "warn",
13343
+ error: "error"
13344
+ };
13345
+ function toastMessage(message) {
13346
+ const flat = message.replace(/\s+/g, " ").trim();
13347
+ if (flat.length <= MAX_TOAST_CHARS) return flat;
13348
+ return `${flat.slice(0, MAX_TOAST_CHARS - 1)}\u2026`;
13349
+ }
13350
+ function appLogBody(entry) {
13351
+ return {
13352
+ service: LOG_SERVICE_NAME,
13353
+ level: APP_LOG_LEVEL[entry.level],
13354
+ message: entry.message,
13355
+ ...entry.data && Object.keys(entry.data).length > 0 ? { extra: entry.data } : {}
13356
+ };
13357
+ }
13358
+ function toastBody(entry) {
13359
+ return {
13360
+ title: TOAST_TITLE,
13361
+ message: toastMessage(entry.message),
13362
+ variant: entry.level === "error" ? "error" : "warning",
13363
+ duration: TOAST_DURATION_MS
13364
+ };
13365
+ }
13366
+ function fireAndForget(call) {
13367
+ try {
13368
+ const result = call();
13369
+ if (result && typeof result.catch === "function") {
13370
+ void result.catch(() => void 0);
13371
+ }
13372
+ } catch {
13373
+ }
13374
+ }
13375
+ function createOpencodeLogSink(client) {
13376
+ if (!client || typeof client !== "object") return null;
13377
+ const sdk = client;
13378
+ const canLog = typeof sdk.app?.log === "function";
13379
+ const canToast = typeof sdk.tui?.showToast === "function";
13380
+ if (!canLog && !canToast) return null;
13381
+ return {
13382
+ log(entry) {
13383
+ if (!canLog) return;
13384
+ fireAndForget(() => sdk.app.log({ body: appLogBody(entry) }));
13385
+ },
13386
+ toast(entry) {
13387
+ if (!canToast) return;
13388
+ fireAndForget(() => sdk.tui.showToast({ body: toastBody(entry) }));
13389
+ }
13390
+ };
13391
+ }
13392
+ function registerOpencodeLogSink(client) {
13393
+ const sink = createOpencodeLogSink(client);
13394
+ if (!sink) return false;
13395
+ setLogSink(sink);
13396
+ return true;
13397
+ }
13398
+
13145
13399
  // src/v2-client.ts
13146
13400
  import path10 from "path";
13147
13401
  function formatAgentTypeList(agents) {
@@ -13693,7 +13947,11 @@ function createClaudeCode(settings = {}) {
13693
13947
  configDir: settings.configDir,
13694
13948
  failoverAccounts: settings.failoverAccounts,
13695
13949
  baseCliPath: settings.baseCliPath ?? cliPath,
13696
- accountFailover: settings.accountFailover ?? "ask",
13950
+ // Passed through, with no default applied here: the form is opt-in and
13951
+ // `isAccountFailoverQuestionActive` asks for an explicit `"ask"`, so an
13952
+ // unset option must stay unset rather than being written to one mode or
13953
+ // the other in only one of the two entry points (h #g194).
13954
+ accountFailover: settings.accountFailover,
13697
13955
  providerID: settings.providerID,
13698
13956
  skipPermissions: preset?.skipPermissions ?? settings.skipPermissions ?? true,
13699
13957
  permissionMode: preset?.permissionMode ?? settings.permissionMode,
@@ -13966,7 +14224,9 @@ var server = async (input) => {
13966
14224
  ensureProcessExitCleanup();
13967
14225
  const opencodeVersion = pickOpencodeVersion(input);
13968
14226
  if (input && typeof input === "object" && "client" in input) {
13969
- setOpencodeClient(input.client);
14227
+ const client = input.client;
14228
+ setOpencodeClient(client);
14229
+ registerOpencodeLogSink(client);
13970
14230
  }
13971
14231
  setOpencodeProjectDirectory(pickOpencodeDirectory(input));
13972
14232
  return {