@khalilgharbaoui/opencode-claude-code-plugin 0.37.1 → 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
@@ -584,6 +584,20 @@ function formatResetsAt(resetsAt) {
584
584
  const date = new Date(ms);
585
585
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
586
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
+ }
587
601
  function rateLimitKey(info) {
588
602
  return [
589
603
  info.status ?? "?",
@@ -599,7 +613,7 @@ function isRateLimitRejected(info) {
599
613
  }
600
614
  function describeRateLimit(info) {
601
615
  if (!info.status && !info.overageStatus) return null;
602
- const window = info.rateLimitType ? RATE_LIMIT_WINDOWS[info.rateLimitType] ?? info.rateLimitType : void 0;
616
+ const window = describeRateLimitWindow(info.rateLimitType);
603
617
  const resets = formatResetsAt(info.resetsAt);
604
618
  const overageResets = formatResetsAt(info.overageResetsAt);
605
619
  const key = rateLimitKey(info);
@@ -614,9 +628,7 @@ function describeRateLimit(info) {
614
628
  if (resetAt) parts.push(`Resets at ${resetAt}.`);
615
629
  parts.push(RATE_LIMIT_ACTION);
616
630
  const message = parts.join(" ");
617
- return { key, level: "warn", message, transcript: `
618
- ${RATE_LIMIT_MARKER} ${message}
619
- ` };
631
+ return { key, level: "warn", message, transcript: null };
620
632
  }
621
633
  if (info.status === "allowed_warning" || info.overageStatus === "allowed_warning") {
622
634
  const used = info.utilization === void 0 ? "" : ` (${Math.round(info.utilization * 100)}% used)`;
@@ -1083,6 +1095,7 @@ var PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?";
1083
1095
  var OPENCODE_QUESTION_RESULT_PREFIX = "User has answered your questions: ";
1084
1096
  var OPENCODE_QUESTION_RESULT_SUFFIX = ". You can now continue with the user's answers in mind.";
1085
1097
  var OPENCODE_UNANSWERED = "Unanswered";
1098
+ var DCP_MESSAGE_ID_SUFFIX = /(?:\s*<dcp-message-id>[^<>]*<\/dcp-message-id>)+\s*$/;
1086
1099
  var KEY_SEPARATOR = "\0";
1087
1100
  function isPlanModeQuestionActive(input) {
1088
1101
  if (input.compactionMode) return false;
@@ -1182,15 +1195,16 @@ function unwrapToolOutput(part) {
1182
1195
  }
1183
1196
  }
1184
1197
  function unwrapOpencodeQuestionResult(value, question) {
1185
- const sentence = value.trim();
1198
+ const untagged = value.replace(DCP_MESSAGE_ID_SUFFIX, "");
1199
+ const sentence = untagged.trim();
1186
1200
  if (!sentence.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) || !sentence.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
1187
- return value;
1201
+ return untagged;
1188
1202
  }
1189
1203
  const body = sentence.slice(
1190
1204
  OPENCODE_QUESTION_RESULT_PREFIX.length,
1191
1205
  sentence.length - OPENCODE_QUESTION_RESULT_SUFFIX.length
1192
1206
  );
1193
- if (!body.startsWith('"') || !body.endsWith('"')) return value;
1207
+ if (!body.startsWith('"') || !body.endsWith('"')) return untagged;
1194
1208
  let answer;
1195
1209
  const head = question === void 0 ? void 0 : `"${question}"="`;
1196
1210
  if (head && body.startsWith(head) && body.length > head.length) {
@@ -1199,7 +1213,7 @@ function unwrapOpencodeQuestionResult(value, question) {
1199
1213
  const split = body.lastIndexOf('"="');
1200
1214
  if (split > 0) answer = body.slice(split + 3, -1);
1201
1215
  }
1202
- if (answer === void 0) return value;
1216
+ if (answer === void 0) return untagged;
1203
1217
  return answer === OPENCODE_UNANSWERED ? "" : answer;
1204
1218
  }
1205
1219
  function collectAnswerStrings(value, question) {
@@ -1257,6 +1271,13 @@ var ACCOUNT_LIMIT_PATTERNS = [
1257
1271
  /you[’'`]?ve hit your individual spend limit/i
1258
1272
  ];
1259
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
+ }
1260
1281
  var ACCOUNT_BLOCKS = {
1261
1282
  authentication_failed: { what: "is not logged in (its login expired or was revoked)", login: true },
1262
1283
  oauth_org_not_allowed: { what: "belongs to an organization that does not allow this login", login: false },
@@ -1280,17 +1301,51 @@ function formatAccountBlockNote(input) {
1280
1301
  const block = ACCOUNT_BLOCKS[input.kind];
1281
1302
  const account = normalizeAccountName(input.account || DEFAULT_ACCOUNT);
1282
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.";
1283
- 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.` : "";
1284
1306
  return `
1285
1307
  ${ACCOUNT_BLOCK_MARKER} the Claude account "${account}" ${block.what}. ${fix}${then}
1286
1308
  `;
1287
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
+ }
1288
1326
  function isAccountLimitError(input) {
1289
1327
  if (input.rateLimit && isRateLimitRejected(input.rateLimit)) return true;
1290
1328
  const text = input.resultText;
1291
1329
  if (typeof text !== "string" || text.length === 0) return false;
1292
1330
  return ACCOUNT_LIMIT_PATTERNS.some((pattern) => pattern.test(text));
1293
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
+ }
1294
1349
  var accountOverrides = /* @__PURE__ */ new Map();
1295
1350
  function setAccountOverride(source, target, until, now = Date.now()) {
1296
1351
  const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
@@ -1358,7 +1413,7 @@ function isAccountFailoverQuestionActive(input) {
1358
1413
  if (input.compactionMode) return false;
1359
1414
  if (input.interactive) return false;
1360
1415
  if (input.childSession) return false;
1361
- if (input.configured === "off") return false;
1416
+ if (input.configured !== "ask") return false;
1362
1417
  if (input.candidates.length === 0) return false;
1363
1418
  return input.opencodeHasQuestion;
1364
1419
  }
@@ -5474,6 +5529,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
5474
5529
  "btw: early aside failed; the message will ask again",
5475
5530
  "cannot interrupt this transport; waiting for the turn to end",
5476
5531
  "claude --version output unparseable",
5532
+ "claude account is out of usage; the turn ends with a note",
5477
5533
  "claude already registers a different skill under this name; not bridging ours",
5478
5534
  "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
5479
5535
  "claude cli does not support --plugin-dir; opencode skills will not be bridged. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
@@ -6328,11 +6384,6 @@ function compareBuilds(loaded, onDisk) {
6328
6384
  rebuiltAt: onDisk.mtimeMs
6329
6385
  };
6330
6386
  }
6331
- function formatLocalMinute(ms) {
6332
- const when = new Date(ms);
6333
- const pad = (value) => String(value).padStart(2, "0");
6334
- return `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())} ${pad(when.getHours())}:${pad(when.getMinutes())}`;
6335
- }
6336
6387
  function formatStaleBuildNote(stale) {
6337
6388
  const loadedAt = formatLocalMinute(stale.loadedAt);
6338
6389
  if (stale.kind === "version") {
@@ -7558,6 +7609,7 @@ var PLUGIN_NOTE_MARKERS = [
7558
7609
  SILENT_TURN_MARKER,
7559
7610
  FAILOVER_MARKER,
7560
7611
  ACCOUNT_BLOCK_MARKER,
7612
+ USAGE_LIMIT_MARKER,
7561
7613
  MODEL_FALLBACK_MARKER,
7562
7614
  UNATTENDED_REPLAY_MARKER,
7563
7615
  STALE_BUILD_MARKER
@@ -9968,6 +10020,7 @@ function createTurnState(init) {
9968
10020
  lastCallUsage: void 0,
9969
10021
  resultFailure: void 0,
9970
10022
  accountLimitHit: null,
10023
+ usageLimitNote: false,
9971
10024
  accountBlock: null,
9972
10025
  modelRefusal: null,
9973
10026
  drainBuffer: [],
@@ -10748,6 +10801,7 @@ function createLineHandler(state, ctx) {
10748
10801
  resetsAt: info.resetsAt ?? info.overageResetsAt,
10749
10802
  window: info.rateLimitType
10750
10803
  };
10804
+ rememberAccountLimit(ctx.sourceAccount, state.accountLimitHit);
10751
10805
  }
10752
10806
  const note = reportRateLimitEvent(msg);
10753
10807
  if (note) {
@@ -11031,6 +11085,13 @@ ${plan}
11031
11085
  if (hasText) {
11032
11086
  state.hasReceivedContent = true;
11033
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
+ }
11034
11095
  if (hasText && !hasToolUse) {
11035
11096
  startResultFallback(state);
11036
11097
  }
@@ -11039,6 +11100,7 @@ ${plan}
11039
11100
  }
11040
11101
  for (const block2 of msg.message.content) {
11041
11102
  if (block2.type === "text" && block2.text) {
11103
+ if (limitErrorReply) continue;
11042
11104
  resetLastVisibleTextBlock(state);
11043
11105
  const blockId = state.startTextBlock();
11044
11106
  state.controller.enqueue({
@@ -11251,7 +11313,15 @@ ${plan}
11251
11313
  if (deliverPendingCompletions(state)) {
11252
11314
  return;
11253
11315
  }
11254
- 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) {
11255
11325
  const errId = state.startTextBlock();
11256
11326
  state.controller.enqueue({
11257
11327
  type: "text-delta",
@@ -11259,11 +11329,6 @@ ${plan}
11259
11329
  delta: msg.result
11260
11330
  });
11261
11331
  }
11262
- if (!state.accountLimitHit && msg.is_error && isAccountLimitError({
11263
- resultText: typeof msg.result === "string" ? msg.result : null
11264
- })) {
11265
- state.accountLimitHit = {};
11266
- }
11267
11332
  if (ctx.modelFallbackArmed && !state.modelRefusal) {
11268
11333
  state.modelRefusal = modelRefusalFromResult(msg);
11269
11334
  }
@@ -11277,7 +11342,11 @@ ${plan}
11277
11342
  kind: state.accountBlock,
11278
11343
  account: ctx.sourceAccount,
11279
11344
  configDir: ctx.config.configDir,
11280
- 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
11281
11350
  })
11282
11351
  });
11283
11352
  state.endTextBlock();
@@ -12156,7 +12225,7 @@ var ClaudeCodeLanguageModel = class {
12156
12225
  this.config.failoverAccounts,
12157
12226
  sourceAccount
12158
12227
  );
12159
- 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
12160
12229
  // rate-limit error, never the form. See `TurnMode`.
12161
12230
  mode !== "generate" && isAccountFailoverQuestionActive({
12162
12231
  configured: this.config.accountFailover,
@@ -12169,6 +12238,7 @@ var ClaudeCodeLanguageModel = class {
12169
12238
  // the operator is usually not even looking at.
12170
12239
  childSession: !!await fetchSessionParentId(affinity)
12171
12240
  });
12241
+ const usageLimitNoteActive = !compactionMode && mode !== "generate" && !failoverAskActive;
12172
12242
  let staleBuild = null;
12173
12243
  if (!compactionMode && mode !== "generate" && affinity !== "default") {
12174
12244
  staleBuild = staleBuildWatch().check();
@@ -12716,6 +12786,26 @@ var ClaudeCodeLanguageModel = class {
12716
12786
  return;
12717
12787
  }
12718
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
+ }
12719
12809
  if (runAutoContinue(state, msg)) return;
12720
12810
  for (const [idx, reasoningId] of state.reasoningIds) {
12721
12811
  if (state.reasoningStarted.get(idx)) {
@@ -12789,6 +12879,8 @@ var ClaudeCodeLanguageModel = class {
12789
12879
  planModeQuestionActive,
12790
12880
  sourceAccount,
12791
12881
  failoverAskActive,
12882
+ usageLimitNoteActive,
12883
+ failoverAccounts,
12792
12884
  modelFallbackArmed,
12793
12885
  attempt,
12794
12886
  handleControlRequest: handleControlRequest2,
@@ -13855,7 +13947,11 @@ function createClaudeCode(settings = {}) {
13855
13947
  configDir: settings.configDir,
13856
13948
  failoverAccounts: settings.failoverAccounts,
13857
13949
  baseCliPath: settings.baseCliPath ?? cliPath,
13858
- 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,
13859
13955
  providerID: settings.providerID,
13860
13956
  skipPermissions: preset?.skipPermissions ?? settings.skipPermissions ?? true,
13861
13957
  permissionMode: preset?.permissionMode ?? settings.permissionMode,