@khalilgharbaoui/opencode-claude-code-plugin 0.23.0 → 0.24.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
@@ -127,6 +127,7 @@ var log = {
127
127
  // src/todo-ledger.ts
128
128
  var ledgers = /* @__PURE__ */ new Map();
129
129
  var PENDING_CREATE_TTL_MS = 6e4;
130
+ var MAX_LEDGER_SESSIONS = 64;
130
131
  var TASK_CREATED_PATTERN = /Task\s*#?\s*(\d+)\s+created/i;
131
132
  var VALID_STATUSES = /* @__PURE__ */ new Set(["pending", "in_progress", "completed"]);
132
133
  function getOrCreate(sessionId) {
@@ -134,9 +135,18 @@ function getOrCreate(sessionId) {
134
135
  if (!ledger) {
135
136
  ledger = { todos: /* @__PURE__ */ new Map(), pendingCreates: /* @__PURE__ */ new Map() };
136
137
  ledgers.set(sessionId, ledger);
138
+ capLedgers();
137
139
  }
138
140
  return ledger;
139
141
  }
142
+ function capLedgers() {
143
+ while (ledgers.size > MAX_LEDGER_SESSIONS) {
144
+ const oldest = ledgers.keys().next();
145
+ if (oldest.done) break;
146
+ ledgers.delete(oldest.value);
147
+ log.info("todo ledger evicted oldest session", { sessionId: oldest.value });
148
+ }
149
+ }
140
150
  function prunePending(ledger) {
141
151
  const cutoff = Date.now() - PENDING_CREATE_TTL_MS;
142
152
  for (const [id, pending] of ledger.pendingCreates) {
@@ -345,35 +355,829 @@ function mapTool(name, input, opts) {
345
355
  log.debug("WebSearch executed by Claude CLI", { mappedInput });
346
356
  return { name: "WebSearch", input: mappedInput, executed: true, skip: true };
347
357
  }
348
- if (name === "TaskOutput") {
349
- if (!input) return { name: "bash", executed: false };
350
- const output = input?.content || input?.output || JSON.stringify(input);
358
+ if (name === "TaskOutput") {
359
+ if (!input) return { name: "bash", executed: false };
360
+ const output = input?.content || input?.output || JSON.stringify(input);
361
+ return {
362
+ name: "bash",
363
+ input: {
364
+ command: `printf '%s\\n' ${singleQuoteForShell(`TASK OUTPUT: ${String(output)}`)}`,
365
+ description: "Displaying task output"
366
+ },
367
+ executed: false
368
+ };
369
+ }
370
+ if (name.startsWith("mcp__")) {
371
+ const parts = name.slice(5).split("__");
372
+ if (parts.length >= 2) {
373
+ const serverName = parts[0];
374
+ const toolName = parts.slice(1).join("_");
375
+ const openCodeName = `${serverName}_${toolName}`;
376
+ log.debug("mapping MCP tool", { original: name, mapped: openCodeName });
377
+ return { name: openCodeName, input, executed: true };
378
+ }
379
+ }
380
+ if (OPENCODE_HANDLED_TOOLS.has(name)) {
381
+ const mappedInput = mapToolInput(name, input);
382
+ const openCodeName = name.toLowerCase();
383
+ log.debug("mapping CLI-executed tool", { name, openCodeName });
384
+ return { name: openCodeName, input: mappedInput, executed: true };
385
+ }
386
+ return { name, input, executed: true };
387
+ }
388
+
389
+ // src/accounts.ts
390
+ import { chmod, lstat, mkdir, readlink, symlink, writeFile } from "fs/promises";
391
+ import path from "path";
392
+ var BASE_PROVIDER_ID = "claude-code";
393
+ var DEFAULT_ACCOUNT = "default";
394
+ var SHARED_CAPABILITY_ITEMS = [
395
+ "CLAUDE.md",
396
+ "settings.json",
397
+ "skills",
398
+ "agents",
399
+ "commands",
400
+ "plugins"
401
+ ];
402
+ function normalizeAccountName(account) {
403
+ return account.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
404
+ }
405
+ function resolveAccounts(value) {
406
+ if (!Array.isArray(value)) return null;
407
+ const accounts = value.map((account) => normalizeAccountName(String(account))).filter(Boolean);
408
+ return Array.from(/* @__PURE__ */ new Set([DEFAULT_ACCOUNT, ...accounts]));
409
+ }
410
+ function accountProviderId(account) {
411
+ return `${BASE_PROVIDER_ID}-${normalizeAccountName(account)}`;
412
+ }
413
+ function accountDisplayName(account) {
414
+ return `Claude Code (${titleizeAccount(account)})`;
415
+ }
416
+ function accountModelSuffix(account) {
417
+ const normalized = normalizeAccountName(account);
418
+ return normalized === DEFAULT_ACCOUNT ? void 0 : normalized;
419
+ }
420
+ function accountConfigDir(account) {
421
+ const normalized = normalizeAccountName(account);
422
+ if (!normalized || normalized === DEFAULT_ACCOUNT) return void 0;
423
+ return `~/.claude-${normalized}`;
424
+ }
425
+ function expandHome(value) {
426
+ const home = process.env.HOME ?? process.env.USERPROFILE;
427
+ if (value === "~") return home ?? value;
428
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
429
+ return home ? path.join(home, value.slice(2)) : value;
430
+ }
431
+ return value;
432
+ }
433
+ async function ensureAccountRuntime(account, baseCliPath) {
434
+ const configDir = accountConfigDir(account);
435
+ if (!configDir) return { cliPath: baseCliPath };
436
+ const expandedConfigDir = expandHome(configDir);
437
+ await mkdir(expandedConfigDir, { recursive: true });
438
+ try {
439
+ await ensureSharedCapabilities(expandedConfigDir);
440
+ } catch (err) {
441
+ log.warn("failed to symlink shared capabilities; continuing anyway", {
442
+ account,
443
+ configDir: expandedConfigDir,
444
+ error: String(err)
445
+ });
446
+ }
447
+ const cliPath = await writeAccountWrapper(
448
+ normalizeAccountName(account),
449
+ baseCliPath,
450
+ expandedConfigDir
451
+ );
452
+ return { cliPath, configDir: expandedConfigDir };
453
+ }
454
+ async function ensureSharedCapabilities(targetRoot) {
455
+ const sourceRoot = expandHome("~/.claude");
456
+ for (const item of SHARED_CAPABILITY_ITEMS) {
457
+ await ensureSharedCapabilityItem(sourceRoot, targetRoot, item);
458
+ }
459
+ }
460
+ async function ensureSharedCapabilityItem(sourceRoot, targetRoot, item) {
461
+ const source = path.join(sourceRoot, item);
462
+ const target = path.join(targetRoot, item);
463
+ let sourceStat;
464
+ try {
465
+ sourceStat = await lstat(source);
466
+ } catch {
467
+ return;
468
+ }
469
+ try {
470
+ const targetStat = await lstat(target);
471
+ if (targetStat.isSymbolicLink()) {
472
+ const current = await readlink(target);
473
+ const resolvedCurrent = path.resolve(path.dirname(target), current);
474
+ const resolvedSource = path.resolve(source);
475
+ if (resolvedCurrent === resolvedSource) return;
476
+ }
477
+ log.warn("shared Claude capability already exists; leaving untouched", {
478
+ item,
479
+ target,
480
+ source
481
+ });
482
+ return;
483
+ } catch {
484
+ }
485
+ const type = sourceStat.isDirectory() ? process.platform === "win32" ? "junction" : "dir" : "file";
486
+ await symlink(source, target, type);
487
+ }
488
+ async function writeAccountWrapper(account, baseCliPath, configDir) {
489
+ const cacheRoot = path.join(
490
+ process.env.XDG_CACHE_HOME ?? expandHome("~/.cache"),
491
+ "opencode-claude-code-plugin"
492
+ );
493
+ const wrapperPath = path.join(cacheRoot, `claude-${account}`);
494
+ const suffix = `@${account}`;
495
+ await mkdir(cacheRoot, { recursive: true });
496
+ const script = `#!/usr/bin/env bash
497
+ set -euo pipefail
498
+
499
+ args=()
500
+ while [[ $# -gt 0 ]]; do
501
+ if [[ "$1" == "--model" && $# -ge 2 ]]; then
502
+ model="$2"
503
+ if [[ "$model" == *${shellDoubleQuote(suffix)} ]]; then
504
+ model="\${model%${shellDoubleQuote(suffix)}}"
505
+ fi
506
+ args+=("$1" "$model")
507
+ shift 2
508
+ else
509
+ args+=("$1")
510
+ shift
511
+ fi
512
+ done
513
+
514
+ export CLAUDE_CONFIG_DIR=${shellSingleQuote(configDir)}
515
+ exec ${shellSingleQuote(baseCliPath)} "\${args[@]}"
516
+ `;
517
+ await writeFile(wrapperPath, script, "utf8");
518
+ await chmod(wrapperPath, 493);
519
+ return wrapperPath;
520
+ }
521
+ function shellSingleQuote(value) {
522
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
523
+ }
524
+ function shellDoubleQuote(value) {
525
+ return value.replace(/[$`"\\]/g, "\\$&");
526
+ }
527
+ function titleizeAccount(account) {
528
+ return normalizeAccountName(account).split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
529
+ }
530
+
531
+ // src/cli-events.ts
532
+ function isRecord(value) {
533
+ return value !== null && typeof value === "object" && !Array.isArray(value);
534
+ }
535
+ function str(value) {
536
+ return typeof value === "string" && value.length > 0 ? value : void 0;
537
+ }
538
+ function num(value) {
539
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
540
+ }
541
+ var RATE_LIMIT_MARKER = "\u258C **rate limit:**";
542
+ var RATE_LIMIT_ACTION = "Enable extra usage or add credits on the account, wait for the window to reset, switch account, org or plan, or authenticate with an API key.";
543
+ var OVERAGE_DISABLED_REASONS = {
544
+ overage_not_provisioned: "extra usage is not set up on this account",
545
+ org_level_disabled: "extra usage is disabled for your organization",
546
+ org_level_disabled_until: "extra usage is disabled for your organization for now",
547
+ out_of_credits: "the account's usage credits are spent",
548
+ seat_tier_level_disabled: "your seat tier does not allow extra usage",
549
+ member_level_disabled: "extra usage is disabled for your member account",
550
+ seat_tier_zero_credit_limit: "your seat tier has a zero credit limit",
551
+ group_zero_credit_limit: "your group has a zero credit limit",
552
+ member_zero_credit_limit: "your member account has a zero credit limit",
553
+ org_service_level_disabled: "your organization's service level does not include extra usage",
554
+ no_limits_configured: "no usage limits are configured for this account",
555
+ fetch_error: "the CLI could not read the account's usage limits"
556
+ };
557
+ var RATE_LIMIT_WINDOWS = {
558
+ five_hour: "the 5-hour window",
559
+ seven_day: "the 7-day window",
560
+ seven_day_opus: "the 7-day Opus window",
561
+ seven_day_sonnet: "the 7-day Sonnet window",
562
+ seven_day_overage_included: "the 7-day extra-usage window",
563
+ overage: "extra usage"
564
+ };
565
+ function parseRateLimitEvent(msg) {
566
+ if (msg.type !== "rate_limit_event") return null;
567
+ const info = msg.rate_limit_info;
568
+ if (!isRecord(info)) return null;
569
+ return {
570
+ status: str(info.status),
571
+ rateLimitType: str(info.rateLimitType),
572
+ resetsAt: num(info.resetsAt),
573
+ utilization: num(info.utilization),
574
+ isUsingOverage: info.isUsingOverage === true,
575
+ overageStatus: str(info.overageStatus),
576
+ overageResetsAt: num(info.overageResetsAt),
577
+ overageDisabledReason: str(info.overageDisabledReason)
578
+ };
579
+ }
580
+ function resetsAtToMs(resetsAt) {
581
+ if (resetsAt === void 0 || !Number.isFinite(resetsAt)) return void 0;
582
+ return resetsAt < 1e12 ? resetsAt * 1e3 : resetsAt;
583
+ }
584
+ function formatResetsAt(resetsAt) {
585
+ const ms = resetsAtToMs(resetsAt);
586
+ if (ms === void 0) return void 0;
587
+ const date = new Date(ms);
588
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
589
+ }
590
+ function rateLimitKey(info) {
591
+ return [
592
+ info.status ?? "?",
593
+ info.rateLimitType ?? "?",
594
+ info.overageStatus ?? "?",
595
+ info.overageDisabledReason ?? "?"
596
+ ].join("|");
597
+ }
598
+ function isRateLimitRejected(info) {
599
+ return info.status === "rejected" || info.overageStatus === "rejected";
600
+ }
601
+ function describeRateLimit(info) {
602
+ if (!info.status && !info.overageStatus) return null;
603
+ const window = info.rateLimitType ? RATE_LIMIT_WINDOWS[info.rateLimitType] ?? info.rateLimitType : void 0;
604
+ const resets = formatResetsAt(info.resetsAt);
605
+ const overageResets = formatResetsAt(info.overageResetsAt);
606
+ const key = rateLimitKey(info);
607
+ if (isRateLimitRejected(info)) {
608
+ const parts = [];
609
+ parts.push(
610
+ info.status === "rejected" ? `Claude Code rejected this request: you are out of usage${window ? ` in ${window}` : ""}.` : "Claude Code rejected this request: paid extra usage is not available on this account."
611
+ );
612
+ const reason = info.overageDisabledReason ? OVERAGE_DISABLED_REASONS[info.overageDisabledReason] ?? info.overageDisabledReason : void 0;
613
+ if (reason) parts.push(`Extra usage is unavailable because ${reason}.`);
614
+ const resetAt = resets ?? overageResets;
615
+ if (resetAt) parts.push(`Resets at ${resetAt}.`);
616
+ parts.push(RATE_LIMIT_ACTION);
617
+ const message = parts.join(" ");
618
+ return { key, level: "warn", message, transcript: `
619
+ ${RATE_LIMIT_MARKER} ${message}
620
+ ` };
621
+ }
622
+ if (info.status === "allowed_warning" || info.overageStatus === "allowed_warning") {
623
+ const used = info.utilization === void 0 ? "" : ` (${Math.round(info.utilization * 100)}% used)`;
624
+ return {
625
+ key,
626
+ level: "notice",
627
+ message: `Approaching the usage limit${window ? ` for ${window}` : ""}${used}${resets ? `, resets at ${resets}` : ""}.`,
628
+ transcript: null
629
+ };
630
+ }
631
+ return {
632
+ key,
633
+ level: "info",
634
+ message: `usage limits updated${window ? ` for ${window}` : ""}`,
635
+ transcript: null
636
+ };
637
+ }
638
+ var reportedRateLimits = /* @__PURE__ */ new Set();
639
+ function reportRateLimitEvent(msg) {
640
+ const info = parseRateLimitEvent(msg);
641
+ if (!info) return null;
642
+ const report = describeRateLimit(info);
643
+ if (!report) return null;
644
+ const data = { ...info };
645
+ if (reportedRateLimits.has(report.key)) {
646
+ log.debug(report.message, data);
647
+ return null;
648
+ }
649
+ reportedRateLimits.add(report.key);
650
+ log[report.level](report.message, data);
651
+ return report.transcript;
652
+ }
653
+ var API_KEY_SOURCES = /* @__PURE__ */ new Set([
654
+ "ANTHROPIC_API_KEY",
655
+ "apiKeyHelper",
656
+ "/login managed key",
657
+ "user",
658
+ "project",
659
+ "org",
660
+ "temporary"
661
+ ]);
662
+ function parseSystemInit(msg) {
663
+ if (msg.type !== "system" || msg.subtype !== "init") return null;
664
+ const raw = msg;
665
+ const servers = [];
666
+ if (Array.isArray(raw.mcp_servers)) {
667
+ for (const entry of raw.mcp_servers) {
668
+ if (!isRecord(entry)) continue;
669
+ servers.push({ name: str(entry.name) ?? "unknown", status: str(entry.status) ?? "unknown" });
670
+ }
671
+ }
672
+ return {
673
+ apiKeySource: str(raw.apiKeySource),
674
+ permissionMode: str(raw.permissionMode),
675
+ model: str(raw.model),
676
+ cliVersion: str(raw.claude_code_version),
677
+ toolCount: Array.isArray(raw.tools) ? raw.tools.length : 0,
678
+ mcpServers: servers
679
+ };
680
+ }
681
+ function apiKeySourceWarning(apiKeySource, ignoreAnthropicApiKey) {
682
+ if (!apiKeySource || !API_KEY_SOURCES.has(apiKeySource)) return null;
683
+ const base = `Claude Code authenticated with an API key (apiKeySource: ${apiKeySource}), so these turns bill as pay-as-you-go API usage instead of your subscription's Agent SDK credit.`;
684
+ return ignoreAnthropicApiKey ? `${base} \`ignoreAnthropicApiKey\` is already on, so the key is not coming from the environment: check the CLI's own settings (\`claude config\`) or an \`apiKeyHelper\`.` : `${base} Set the provider option \`ignoreAnthropicApiKey: true\` to strip the key from spawns and fall back to the stored subscription auth.`;
685
+ }
686
+ var warnedApiKeySources = /* @__PURE__ */ new Set();
687
+ var warnedMcpFailures = /* @__PURE__ */ new Set();
688
+ function reportSystemInit(msg, options = {}) {
689
+ const info = parseSystemInit(msg);
690
+ if (!info) return;
691
+ log.info("claude session init", {
692
+ apiKeySource: info.apiKeySource ?? null,
693
+ permissionMode: info.permissionMode ?? null,
694
+ model: info.model ?? null,
695
+ cliVersion: info.cliVersion ?? null,
696
+ tools: info.toolCount,
697
+ mcpServers: info.mcpServers
698
+ });
699
+ for (const server2 of info.mcpServers) {
700
+ if (server2.status === "connected") continue;
701
+ const key2 = `${server2.name}:${server2.status}`;
702
+ const message = `MCP server "${server2.name}" is ${server2.status} in Claude Code; its tools are not available to the model this session.`;
703
+ if (warnedMcpFailures.has(key2)) {
704
+ log.debug(message, { server: server2.name, status: server2.status });
705
+ continue;
706
+ }
707
+ warnedMcpFailures.add(key2);
708
+ log.warn(message, { server: server2.name, status: server2.status });
709
+ }
710
+ const apiKeyMessage = apiKeySourceWarning(info.apiKeySource, options.ignoreAnthropicApiKey);
711
+ if (!apiKeyMessage) return;
712
+ const key = `${info.apiKeySource}:${options.ignoreAnthropicApiKey ? "ignored" : "passed"}`;
713
+ if (warnedApiKeySources.has(key)) {
714
+ log.debug(apiKeyMessage, { apiKeySource: info.apiKeySource });
715
+ return;
716
+ }
717
+ warnedApiKeySources.add(key);
718
+ log.warn(apiKeyMessage, { apiKeySource: info.apiKeySource });
719
+ }
720
+ var COMPACT_BOUNDARY_MARKER = "\u258C **context compacted:**";
721
+ function parseCompactBoundary(msg) {
722
+ if (msg.type !== "system" || msg.subtype !== "compact_boundary") return null;
723
+ const raw = msg;
724
+ const meta = isRecord(raw.compact_metadata) ? raw.compact_metadata : isRecord(raw.compactMetadata) ? raw.compactMetadata : void 0;
725
+ return {
726
+ trigger: str(meta?.trigger) ?? "unknown",
727
+ preTokens: num(meta?.pre_tokens) ?? num(meta?.preTokens),
728
+ postTokens: num(meta?.post_tokens) ?? num(meta?.postTokens)
729
+ };
730
+ }
731
+ function formatCompactBoundaryNote(boundary) {
732
+ const how = boundary.trigger === "auto" ? "on its own" : `on a ${boundary.trigger} request`;
733
+ const sizes = boundary.preTokens !== void 0 && boundary.postTokens !== void 0 ? ` (${boundary.preTokens.toLocaleString("en-US")} tokens to ${boundary.postTokens.toLocaleString("en-US")})` : "";
734
+ return `
735
+ ${COMPACT_BOUNDARY_MARKER} Claude Code compacted its own context ${how}${sizes}. Earlier detail in this conversation is now a summary.
736
+ `;
737
+ }
738
+ function reportCompactBoundary(msg) {
739
+ const boundary = parseCompactBoundary(msg);
740
+ if (!boundary) return null;
741
+ log.notice("claude code compacted its own context", {
742
+ trigger: boundary.trigger,
743
+ preTokens: boundary.preTokens ?? null,
744
+ postTokens: boundary.postTokens ?? null
745
+ });
746
+ return formatCompactBoundaryNote(boundary);
747
+ }
748
+ var RESULT_ERROR_MARKER = "\u258C **claude code error:**";
749
+ var RESULT_SUBTYPES = {
750
+ error_max_turns: "it hit its internal turn limit before finishing",
751
+ error_during_execution: "it failed while running the turn",
752
+ error_max_budget_usd: "it hit the configured spend limit for the turn",
753
+ error_max_structured_output_retries: "it could not produce valid structured output"
754
+ };
755
+ function describeResultFailure(msg) {
756
+ if (msg.type !== "result") return null;
757
+ const subtype = msg.subtype;
758
+ if (!subtype || subtype === "success") return null;
759
+ const explanation = RESULT_SUBTYPES[subtype];
760
+ return explanation ? `Claude Code ended the turn with \`${subtype}\`: ${explanation}.` : `Claude Code ended the turn with \`${subtype}\`.`;
761
+ }
762
+ function formatResultFailureNote(message) {
763
+ return `
764
+ ${RESULT_ERROR_MARKER} ${message}
765
+ `;
766
+ }
767
+ var STREAM_TIMEOUT_MARKER = "\u258C **stream timeout:**";
768
+ function formatStreamTimeoutNote(silenceMs) {
769
+ const seconds = Math.max(1, Math.round(silenceMs / 1e3));
770
+ return `
771
+ ${STREAM_TIMEOUT_MARKER} The Claude Code CLI produced output and then went silent for ${seconds}s without finishing the turn, so it was closed without a result. The answer above may be incomplete.
772
+ `;
773
+ }
774
+
775
+ // src/plan-mode-question.ts
776
+ var QUESTION_TOOL_NAME = "question";
777
+ var APPROVED_EXIT_PLAN_MODE_MESSAGE = "User has approved your plan. You can now start coding. Start with updating your todo list if applicable.";
778
+ var REJECTED_EXIT_PLAN_MODE_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:";
779
+ var PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?";
780
+ var OPENCODE_QUESTION_RESULT_PREFIX = `User has answered your questions: "${PLAN_MODE_APPROVAL_QUESTION}"="`;
781
+ var OPENCODE_QUESTION_RESULT_SUFFIX = `". You can now continue with the user's answers in mind.`;
782
+ var KEY_SEPARATOR = "\0";
783
+ function isPlanModeQuestionActive(input) {
784
+ if (input.compactionMode) return false;
785
+ if (input.configured !== true) return false;
786
+ return input.opencodeHasQuestion;
787
+ }
788
+ var pendingQuestions = /* @__PURE__ */ new Map();
789
+ function pendingKey(sessionKey2, questionToolCallId) {
790
+ return `${sessionKey2}${KEY_SEPARATOR}${questionToolCallId}`;
791
+ }
792
+ function clearExitPlanModeQuestions(sessionKey2) {
793
+ const prefix = `${sessionKey2}${KEY_SEPARATOR}`;
794
+ for (const key of pendingQuestions.keys()) {
795
+ if (key.startsWith(prefix)) pendingQuestions.delete(key);
796
+ }
797
+ }
798
+ function hasExitPlanModeQuestions(sessionKey2) {
799
+ const prefix = `${sessionKey2}${KEY_SEPARATOR}`;
800
+ return [...pendingQuestions.keys()].some((key) => key.startsWith(prefix));
801
+ }
802
+ function createExitPlanModeQuestionCall(sessionKey2, exitPlanModeToolUseId, plan, questionToolCallId = `exit_plan_question_${exitPlanModeToolUseId}`) {
803
+ pendingQuestions.set(pendingKey(sessionKey2, questionToolCallId), exitPlanModeToolUseId);
804
+ return {
805
+ toolCallId: questionToolCallId,
806
+ toolName: QUESTION_TOOL_NAME,
807
+ input: {
808
+ questions: [
809
+ {
810
+ header: "Plan approval",
811
+ question: PLAN_MODE_APPROVAL_QUESTION,
812
+ options: [
813
+ { label: "yes", description: "" },
814
+ { label: "no", description: "" }
815
+ ],
816
+ multiple: false,
817
+ custom: true
818
+ }
819
+ ]
820
+ },
821
+ text: plan ? `
822
+
823
+ ${plan}
824
+ ` : "\n\n"
825
+ };
826
+ }
827
+ function buildToolResultMessage(input) {
828
+ return JSON.stringify({
829
+ type: "user",
830
+ message: {
831
+ role: "user",
832
+ content: [
833
+ input.approved ? {
834
+ type: "tool_result",
835
+ tool_use_id: input.toolUseId,
836
+ content: APPROVED_EXIT_PLAN_MODE_MESSAGE
837
+ } : {
838
+ type: "tool_result",
839
+ tool_use_id: input.toolUseId,
840
+ content: `${REJECTED_EXIT_PLAN_MODE_PREFIX}
841
+ ${input.feedback || "no"}`,
842
+ is_error: true
843
+ }
844
+ ]
845
+ }
846
+ });
847
+ }
848
+ function tryParseJson(text) {
849
+ try {
850
+ return JSON.parse(text);
851
+ } catch {
852
+ return text;
853
+ }
854
+ }
855
+ function unwrapToolOutput(part) {
856
+ const output = part?.output ?? part?.result;
857
+ if (typeof output === "string") return tryParseJson(output);
858
+ if (!output || typeof output !== "object") return output;
859
+ switch (output.type) {
860
+ case "json":
861
+ case "error-json":
862
+ return output.value;
863
+ case "text":
864
+ case "error-text":
865
+ return tryParseJson(String(output.value ?? ""));
866
+ case "execution-denied":
867
+ return {
868
+ denied: true,
869
+ reason: String(output.reason ?? "question rejected")
870
+ };
871
+ case "content":
872
+ return Array.isArray(output.value) ? output.value.map((item) => {
873
+ if (item?.type === "text") return item.text;
874
+ return JSON.stringify(item);
875
+ }).join("\n") : output.value;
876
+ default:
877
+ return output;
878
+ }
879
+ }
880
+ function unwrapOpencodeQuestionResult(value) {
881
+ if (value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) && value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
882
+ return value.slice(
883
+ OPENCODE_QUESTION_RESULT_PREFIX.length,
884
+ -OPENCODE_QUESTION_RESULT_SUFFIX.length
885
+ );
886
+ }
887
+ return value;
888
+ }
889
+ function collectAnswerStrings(value) {
890
+ if (typeof value === "string") return [unwrapOpencodeQuestionResult(value)];
891
+ if (Array.isArray(value)) return value.flatMap(collectAnswerStrings);
892
+ if (!value || typeof value !== "object") return [];
893
+ const obj = value;
894
+ if (obj.denied === true) return [String(obj.reason ?? "question rejected")];
895
+ for (const key of ["answers", "answer", "selected", "selection", "value"]) {
896
+ if (key in obj) return collectAnswerStrings(obj[key]);
897
+ }
898
+ return [];
899
+ }
900
+ function classifyQuestionResult(part) {
901
+ const output = unwrapToolOutput(part);
902
+ const answers = collectAnswerStrings(output).map((answer) => answer.trim()).filter(Boolean);
903
+ if (answers.length === 1 && answers[0].toLowerCase() === "yes") {
904
+ return { approved: true, feedback: "" };
905
+ }
906
+ return {
907
+ approved: false,
908
+ feedback: answers.length > 0 ? answers.join("\n") : "no"
909
+ };
910
+ }
911
+ function consumeExitPlanModeQuestionResult(sessionKey2, prompt) {
912
+ for (let i = prompt.length - 1; i >= 0; i--) {
913
+ const msg = prompt[i];
914
+ if (!Array.isArray(msg.content)) continue;
915
+ for (const part of msg.content) {
916
+ if (part?.type !== "tool-result" || typeof part.toolCallId !== "string") {
917
+ continue;
918
+ }
919
+ const key = pendingKey(sessionKey2, part.toolCallId);
920
+ const exitPlanModeToolUseId = pendingQuestions.get(key);
921
+ if (!exitPlanModeToolUseId) continue;
922
+ pendingQuestions.delete(key);
923
+ const result = classifyQuestionResult(part);
924
+ return buildToolResultMessage({
925
+ toolUseId: exitPlanModeToolUseId,
926
+ approved: result.approved,
927
+ feedback: result.feedback
928
+ });
929
+ }
930
+ }
931
+ return null;
932
+ }
933
+
934
+ // src/account-failover.ts
935
+ var FAILOVER_MARKER = "\u258C **account failover:**";
936
+ var ACCOUNT_FAILOVER_TOOL_CALL_PREFIX = "account_failover_";
937
+ var KEY_SEPARATOR2 = "\0";
938
+ var STOP_ANSWER = "stop";
939
+ var ACCOUNT_LIMIT_PATTERNS = [
940
+ /third-party apps now draw from your extra usage/i,
941
+ /you[’'`]?ve hit your individual spend limit/i
942
+ ];
943
+ function isAccountLimitError(input) {
944
+ if (input.rateLimit && isRateLimitRejected(input.rateLimit)) return true;
945
+ const text = input.resultText;
946
+ if (typeof text !== "string" || text.length === 0) return false;
947
+ return ACCOUNT_LIMIT_PATTERNS.some((pattern) => pattern.test(text));
948
+ }
949
+ var accountOverrides = /* @__PURE__ */ new Map();
950
+ function setAccountOverride(source, target, until, now = Date.now()) {
951
+ const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
952
+ const to = normalizeAccountName(target);
953
+ if (!to || to === from) return;
954
+ if (until !== void 0 && until <= now) {
955
+ log.notice("ignoring a failover reset time that is not in the future", {
956
+ source: from,
957
+ target: to,
958
+ until
959
+ });
960
+ until = void 0;
961
+ }
962
+ accountOverrides.set(from, { target: to, until });
963
+ log.warn(
964
+ `Claude account "${from}" is out of usage; this and every other session on it now runs on "${to}"${until ? ` until ${new Date(until).toISOString()}` : " until opencode restarts"}.`,
965
+ { source: from, target: to, until: until ?? null }
966
+ );
967
+ }
968
+ function resolveAccountOverride(source, now = Date.now()) {
969
+ const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
970
+ const entry = accountOverrides.get(from);
971
+ if (!entry) return void 0;
972
+ if (entry.until !== void 0 && entry.until <= now) {
973
+ accountOverrides.delete(from);
974
+ log.notice(
975
+ `Claude account "${from}" should have usage again; switching back from "${entry.target}".`,
976
+ { source: from, target: entry.target, until: entry.until }
977
+ );
978
+ return void 0;
979
+ }
980
+ return entry.target;
981
+ }
982
+ function stripAccountSuffix(modelId) {
983
+ const at = modelId.indexOf("@");
984
+ return at === -1 ? modelId : modelId.slice(0, at);
985
+ }
986
+ async function resolveFailoverSpawn(input) {
987
+ const unchanged = {
988
+ cliPath: input.cliPath,
989
+ modelId: input.modelId,
990
+ failedOver: false
991
+ };
992
+ const source = normalizeAccountName(input.account || DEFAULT_ACCOUNT);
993
+ const target = resolveAccountOverride(source, input.now);
994
+ if (!target) return unchanged;
995
+ try {
996
+ const cliPath = target === DEFAULT_ACCOUNT ? input.baseCliPath : (await ensureAccountRuntime(target, input.baseCliPath)).cliPath;
997
+ return {
998
+ cliPath,
999
+ modelId: stripAccountSuffix(input.modelId),
1000
+ target,
1001
+ failedOver: true
1002
+ };
1003
+ } catch (err) {
1004
+ log.error("failed to prepare the failover account runtime; staying put", {
1005
+ source,
1006
+ target,
1007
+ error: String(err)
1008
+ });
1009
+ return unchanged;
1010
+ }
1011
+ }
1012
+ function isAccountFailoverQuestionActive(input) {
1013
+ if (input.compactionMode) return false;
1014
+ if (input.interactive) return false;
1015
+ if (input.childSession) return false;
1016
+ if (input.configured === "off") return false;
1017
+ if (input.candidates.length === 0) return false;
1018
+ return input.opencodeHasQuestion;
1019
+ }
1020
+ function failoverCandidates(accounts, source) {
1021
+ const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
1022
+ const out = [];
1023
+ for (const raw of accounts ?? []) {
1024
+ const name = normalizeAccountName(String(raw));
1025
+ if (!name || name === from || out.includes(name)) continue;
1026
+ out.push(name);
1027
+ }
1028
+ return out;
1029
+ }
1030
+ var pendingQuestions2 = /* @__PURE__ */ new Map();
1031
+ function pendingKey2(sessionKey2, toolCallId) {
1032
+ return `${sessionKey2}${KEY_SEPARATOR2}${toolCallId}`;
1033
+ }
1034
+ function clearAccountFailoverQuestions(sessionKey2) {
1035
+ const prefix = `${sessionKey2}${KEY_SEPARATOR2}`;
1036
+ for (const key of pendingQuestions2.keys()) {
1037
+ if (key.startsWith(prefix)) pendingQuestions2.delete(key);
1038
+ }
1039
+ }
1040
+ function describeReset(resetsAt) {
1041
+ return formatResetsAt(resetsAt);
1042
+ }
1043
+ function createAccountFailoverQuestionCall(sessionKey2, input, toolCallId = `${ACCOUNT_FAILOVER_TOOL_CALL_PREFIX}${Math.random().toString(36).slice(2, 10)}`) {
1044
+ const source = normalizeAccountName(input.sourceAccount || DEFAULT_ACCOUNT);
1045
+ const candidates = input.candidates.map((c) => normalizeAccountName(c));
1046
+ const resets = describeReset(input.resetsAt);
1047
+ const until = resets ?? "opencode restarts";
1048
+ pendingQuestions2.set(pendingKey2(sessionKey2, toolCallId), {
1049
+ sourceAccount: source,
1050
+ candidates: [...candidates],
1051
+ resetsAt: input.resetsAt
1052
+ });
1053
+ const question = [
1054
+ `The Claude account "${source}" is out of usage`,
1055
+ input.window ? ` in ${input.window}` : "",
1056
+ resets ? `, which resets at ${resets}` : "",
1057
+ ". Continue this task on another configured account? Leaving this unanswered waits, at no cost."
1058
+ ].join("");
1059
+ return {
1060
+ toolCallId,
1061
+ toolName: QUESTION_TOOL_NAME,
1062
+ input: {
1063
+ questions: [
1064
+ {
1065
+ header: "Account limit",
1066
+ question,
1067
+ options: [
1068
+ ...candidates.map((candidate) => ({
1069
+ label: candidate,
1070
+ description: `Run on "${candidate}" until ${until}. The conversation is replayed as a fresh Claude session (a session cannot resume across accounts), and any MCP server configured only in "${source}"'s Claude profile will be missing.`
1071
+ })),
1072
+ {
1073
+ label: STOP_ANSWER,
1074
+ description: "End this turn now and leave the account as it is."
1075
+ }
1076
+ ],
1077
+ multiple: false,
1078
+ custom: true
1079
+ }
1080
+ ]
1081
+ },
1082
+ text: ""
1083
+ };
1084
+ }
1085
+ function isDenied(output) {
1086
+ return !!output && typeof output === "object" && output.denied === true;
1087
+ }
1088
+ function classify(pending, part) {
1089
+ const output = unwrapToolOutput(part);
1090
+ if (isDenied(output)) {
351
1091
  return {
352
- name: "bash",
353
- input: {
354
- command: `printf '%s\\n' ${singleQuoteForShell(`TASK OUTPUT: ${String(output)}`)}`,
355
- description: "Displaying task output"
356
- },
357
- executed: false
1092
+ kind: "stop",
1093
+ reason: String(output.reason ?? "question rejected")
358
1094
  };
359
1095
  }
360
- if (name.startsWith("mcp__")) {
361
- const parts = name.slice(5).split("__");
362
- if (parts.length >= 2) {
363
- const serverName = parts[0];
364
- const toolName = parts.slice(1).join("_");
365
- const openCodeName = `${serverName}_${toolName}`;
366
- log.debug("mapping MCP tool", { original: name, mapped: openCodeName });
367
- return { name: openCodeName, input, executed: true };
1096
+ const answers = collectAnswerStrings(output).map((answer) => answer.trim()).filter(Boolean);
1097
+ if (answers.length === 0) return { kind: "stop", reason: "no answer" };
1098
+ const picked = normalizeAccountName(answers[0]);
1099
+ if (picked === STOP_ANSWER) {
1100
+ return { kind: "stop", reason: "the operator chose to stop" };
1101
+ }
1102
+ const target = pending.candidates.find((candidate) => candidate === picked);
1103
+ if (!target) {
1104
+ return { kind: "stop", reason: `unrecognised answer "${answers[0]}"` };
1105
+ }
1106
+ return {
1107
+ kind: "switch",
1108
+ target,
1109
+ sourceAccount: pending.sourceAccount,
1110
+ resetsAt: pending.resetsAt
1111
+ };
1112
+ }
1113
+ function consumeAccountFailoverAnswer(sessionKey2, prompt) {
1114
+ for (let i = prompt.length - 1; i >= 0; i--) {
1115
+ const msg = prompt[i];
1116
+ if (!Array.isArray(msg.content)) continue;
1117
+ for (const part of msg.content) {
1118
+ if (part?.type !== "tool-result" || typeof part.toolCallId !== "string") {
1119
+ continue;
1120
+ }
1121
+ const key = pendingKey2(sessionKey2, part.toolCallId);
1122
+ const pending = pendingQuestions2.get(key);
1123
+ if (!pending) continue;
1124
+ pendingQuestions2.delete(key);
1125
+ return classify(pending, part);
368
1126
  }
369
1127
  }
370
- if (OPENCODE_HANDLED_TOOLS.has(name)) {
371
- const mappedInput = mapToolInput(name, input);
372
- const openCodeName = name.toLowerCase();
373
- log.debug("mapping CLI-executed tool", { name, openCodeName });
374
- return { name: openCodeName, input: mappedInput, executed: true };
1128
+ return null;
1129
+ }
1130
+ function formatFailoverNote(input) {
1131
+ const resets = describeReset(input.resetsAt);
1132
+ return `
1133
+ ${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.
1134
+ `;
1135
+ }
1136
+ function formatFailoverStopNote(reason) {
1137
+ return `
1138
+ ${FAILOVER_MARKER} Staying on this account (${reason}). The turn ends here; the usage limit is unchanged.
1139
+ `;
1140
+ }
1141
+ function isFailoverPart(part) {
1142
+ if (!part || typeof part.toolCallId !== "string") return false;
1143
+ if (part.type !== "tool-call" && part.type !== "tool-result") return false;
1144
+ return part.toolCallId.startsWith(ACCOUNT_FAILOVER_TOOL_CALL_PREFIX);
1145
+ }
1146
+ function stripAccountFailoverParts(prompt) {
1147
+ let changed = false;
1148
+ const out = [];
1149
+ for (const message of prompt) {
1150
+ const content = message.content;
1151
+ if (!Array.isArray(content) || !content.some(isFailoverPart)) {
1152
+ out.push(message);
1153
+ continue;
1154
+ }
1155
+ changed = true;
1156
+ const kept = content.filter((part) => !isFailoverPart(part));
1157
+ if (kept.length === 0) continue;
1158
+ out.push({ ...message, content: kept });
375
1159
  }
376
- return { name, input, executed: true };
1160
+ return changed ? out : prompt;
1161
+ }
1162
+ function failoverContinuationText(target) {
1163
+ return [
1164
+ `The Claude account this conversation was running on hit its usage limit, so it has been moved to the "${target}" account and you are now in a fresh Claude session.`,
1165
+ "The conversation so far is above. Continue the task from where it stopped: do not start over, do not re-plan, and do not repeat work that is already done.",
1166
+ "Do not mention the account switch unless you are asked about it."
1167
+ ].join(" ");
1168
+ }
1169
+ function buildFailoverContinuationPrompt(prompt, target) {
1170
+ const stripped = stripAccountFailoverParts(prompt);
1171
+ return [
1172
+ ...stripped,
1173
+ {
1174
+ role: "user",
1175
+ content: [{ type: "text", text: failoverContinuationText(target) }]
1176
+ }
1177
+ ];
1178
+ }
1179
+ function failoverUntil(resetsAt) {
1180
+ return resetsAtToMs(resetsAt);
377
1181
  }
378
1182
 
379
1183
  // src/cli-version.ts
@@ -489,15 +1293,15 @@ import { unlink } from "fs/promises";
489
1293
  // src/proxy-mcp.ts
490
1294
  import { createServer } from "http";
491
1295
  import * as fs2 from "fs";
492
- import * as path2 from "path";
1296
+ import * as path3 from "path";
493
1297
  import * as crypto from "crypto";
494
1298
  import { EventEmitter } from "events";
495
1299
 
496
1300
  // src/tmp.ts
497
1301
  import * as fs from "fs";
498
1302
  import * as os from "os";
499
- import * as path from "path";
500
- var PLUGIN_TMP_DIR = path.join(
1303
+ import * as path2 from "path";
1304
+ var PLUGIN_TMP_DIR = path2.join(
501
1305
  os.tmpdir(),
502
1306
  `opencode-claude-code-${process.pid}`
503
1307
  );
@@ -1184,7 +1988,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
1184
1988
  2
1185
1989
  );
1186
1990
  const hash = crypto.createHash("sha256").update(body).digest("hex").slice(0, 12);
1187
- const outPath = path2.join(
1991
+ const outPath = path3.join(
1188
1992
  pluginTmpDir(),
1189
1993
  `proxy-${hash}.json`
1190
1994
  );
@@ -1615,181 +2419,22 @@ function rejectPendingProxyCallById(toolCallId, error) {
1615
2419
  clearPendingTimers(pending);
1616
2420
  pending.reject(error);
1617
2421
  log.notice("rejected pending proxy call", {
1618
- sessionKey: pending.sessionKey,
1619
- toolCallId: pending.toolCallId,
1620
- toolName: pending.toolName,
1621
- error: error.message
1622
- });
1623
- return true;
1624
- }
1625
- function rejectAllPendingProxyCallsForSession(sessionKey2, error) {
1626
- const s = callIdsBySession.get(sessionKey2);
1627
- if (!s) return 0;
1628
- const ids = [...s];
1629
- let count = 0;
1630
- for (const id of ids) {
1631
- if (rejectPendingProxyCallById(id, error)) count++;
1632
- }
1633
- return count;
1634
- }
1635
-
1636
- // src/plan-mode-question.ts
1637
- var QUESTION_TOOL_NAME = "question";
1638
- var APPROVED_EXIT_PLAN_MODE_MESSAGE = "User has approved your plan. You can now start coding. Start with updating your todo list if applicable.";
1639
- var REJECTED_EXIT_PLAN_MODE_PREFIX = "The user doesn't want to proceed with this tool use. The tool use was rejected. To tell you how to proceed, the user said:";
1640
- var PLAN_MODE_APPROVAL_QUESTION = "Do you want to proceed with this plan?";
1641
- var OPENCODE_QUESTION_RESULT_PREFIX = `User has answered your questions: "${PLAN_MODE_APPROVAL_QUESTION}"="`;
1642
- var OPENCODE_QUESTION_RESULT_SUFFIX = `". You can now continue with the user's answers in mind.`;
1643
- var KEY_SEPARATOR = "\0";
1644
- function isPlanModeQuestionActive(input) {
1645
- if (input.compactionMode) return false;
1646
- if (input.configured !== true) return false;
1647
- return input.opencodeHasQuestion;
1648
- }
1649
- var pendingQuestions = /* @__PURE__ */ new Map();
1650
- function pendingKey(sessionKey2, questionToolCallId) {
1651
- return `${sessionKey2}${KEY_SEPARATOR}${questionToolCallId}`;
1652
- }
1653
- function clearExitPlanModeQuestions(sessionKey2) {
1654
- const prefix = `${sessionKey2}${KEY_SEPARATOR}`;
1655
- for (const key of pendingQuestions.keys()) {
1656
- if (key.startsWith(prefix)) pendingQuestions.delete(key);
1657
- }
1658
- }
1659
- function hasExitPlanModeQuestions(sessionKey2) {
1660
- const prefix = `${sessionKey2}${KEY_SEPARATOR}`;
1661
- return [...pendingQuestions.keys()].some((key) => key.startsWith(prefix));
1662
- }
1663
- function createExitPlanModeQuestionCall(sessionKey2, exitPlanModeToolUseId, plan, questionToolCallId = `exit_plan_question_${exitPlanModeToolUseId}`) {
1664
- pendingQuestions.set(pendingKey(sessionKey2, questionToolCallId), exitPlanModeToolUseId);
1665
- return {
1666
- toolCallId: questionToolCallId,
1667
- toolName: QUESTION_TOOL_NAME,
1668
- input: {
1669
- questions: [
1670
- {
1671
- header: "Plan approval",
1672
- question: PLAN_MODE_APPROVAL_QUESTION,
1673
- options: [
1674
- { label: "yes", description: "" },
1675
- { label: "no", description: "" }
1676
- ],
1677
- multiple: false,
1678
- custom: true
1679
- }
1680
- ]
1681
- },
1682
- text: plan ? `
1683
-
1684
- ${plan}
1685
- ` : "\n\n"
1686
- };
1687
- }
1688
- function buildToolResultMessage(input) {
1689
- return JSON.stringify({
1690
- type: "user",
1691
- message: {
1692
- role: "user",
1693
- content: [
1694
- input.approved ? {
1695
- type: "tool_result",
1696
- tool_use_id: input.toolUseId,
1697
- content: APPROVED_EXIT_PLAN_MODE_MESSAGE
1698
- } : {
1699
- type: "tool_result",
1700
- tool_use_id: input.toolUseId,
1701
- content: `${REJECTED_EXIT_PLAN_MODE_PREFIX}
1702
- ${input.feedback || "no"}`,
1703
- is_error: true
1704
- }
1705
- ]
1706
- }
1707
- });
1708
- }
1709
- function tryParseJson(text) {
1710
- try {
1711
- return JSON.parse(text);
1712
- } catch {
1713
- return text;
1714
- }
1715
- }
1716
- function unwrapToolOutput(part) {
1717
- const output = part?.output ?? part?.result;
1718
- if (typeof output === "string") return tryParseJson(output);
1719
- if (!output || typeof output !== "object") return output;
1720
- switch (output.type) {
1721
- case "json":
1722
- case "error-json":
1723
- return output.value;
1724
- case "text":
1725
- case "error-text":
1726
- return tryParseJson(String(output.value ?? ""));
1727
- case "execution-denied":
1728
- return {
1729
- denied: true,
1730
- reason: String(output.reason ?? "question rejected")
1731
- };
1732
- case "content":
1733
- return Array.isArray(output.value) ? output.value.map((item) => {
1734
- if (item?.type === "text") return item.text;
1735
- return JSON.stringify(item);
1736
- }).join("\n") : output.value;
1737
- default:
1738
- return output;
1739
- }
1740
- }
1741
- function unwrapOpencodeQuestionResult(value) {
1742
- if (value.startsWith(OPENCODE_QUESTION_RESULT_PREFIX) && value.endsWith(OPENCODE_QUESTION_RESULT_SUFFIX)) {
1743
- return value.slice(
1744
- OPENCODE_QUESTION_RESULT_PREFIX.length,
1745
- -OPENCODE_QUESTION_RESULT_SUFFIX.length
1746
- );
1747
- }
1748
- return value;
1749
- }
1750
- function collectAnswerStrings(value) {
1751
- if (typeof value === "string") return [unwrapOpencodeQuestionResult(value)];
1752
- if (Array.isArray(value)) return value.flatMap(collectAnswerStrings);
1753
- if (!value || typeof value !== "object") return [];
1754
- const obj = value;
1755
- if (obj.denied === true) return [String(obj.reason ?? "question rejected")];
1756
- for (const key of ["answers", "answer", "selected", "selection", "value"]) {
1757
- if (key in obj) return collectAnswerStrings(obj[key]);
1758
- }
1759
- return [];
1760
- }
1761
- function classifyQuestionResult(part) {
1762
- const output = unwrapToolOutput(part);
1763
- const answers = collectAnswerStrings(output).map((answer) => answer.trim()).filter(Boolean);
1764
- if (answers.length === 1 && answers[0].toLowerCase() === "yes") {
1765
- return { approved: true, feedback: "" };
1766
- }
1767
- return {
1768
- approved: false,
1769
- feedback: answers.length > 0 ? answers.join("\n") : "no"
1770
- };
1771
- }
1772
- function consumeExitPlanModeQuestionResult(sessionKey2, prompt) {
1773
- for (let i = prompt.length - 1; i >= 0; i--) {
1774
- const msg = prompt[i];
1775
- if (!Array.isArray(msg.content)) continue;
1776
- for (const part of msg.content) {
1777
- if (part?.type !== "tool-result" || typeof part.toolCallId !== "string") {
1778
- continue;
1779
- }
1780
- const key = pendingKey(sessionKey2, part.toolCallId);
1781
- const exitPlanModeToolUseId = pendingQuestions.get(key);
1782
- if (!exitPlanModeToolUseId) continue;
1783
- pendingQuestions.delete(key);
1784
- const result = classifyQuestionResult(part);
1785
- return buildToolResultMessage({
1786
- toolUseId: exitPlanModeToolUseId,
1787
- approved: result.approved,
1788
- feedback: result.feedback
1789
- });
1790
- }
2422
+ sessionKey: pending.sessionKey,
2423
+ toolCallId: pending.toolCallId,
2424
+ toolName: pending.toolName,
2425
+ error: error.message
2426
+ });
2427
+ return true;
2428
+ }
2429
+ function rejectAllPendingProxyCallsForSession(sessionKey2, error) {
2430
+ const s = callIdsBySession.get(sessionKey2);
2431
+ if (!s) return 0;
2432
+ const ids = [...s];
2433
+ let count = 0;
2434
+ for (const id of ids) {
2435
+ if (rejectPendingProxyCallById(id, error)) count++;
1791
2436
  }
1792
- return null;
2437
+ return count;
1793
2438
  }
1794
2439
 
1795
2440
  // src/compression-store.ts
@@ -1822,7 +2467,7 @@ import { randomUUID as randomUUID2 } from "crypto";
1822
2467
  var MAX_HISTORY_EXCHANGES = 20;
1823
2468
  var SIDE_QUESTION_USAGE = "Usage: /btw <question>. Ask a side question about the current conversation without adding it to the main context.";
1824
2469
  var pendingProcesses = /* @__PURE__ */ new WeakSet();
1825
- function isRecord(value) {
2470
+ function isRecord2(value) {
1826
2471
  return value !== null && typeof value === "object" && !Array.isArray(value);
1827
2472
  }
1828
2473
  var SYSTEM_REMINDER_BLOCK = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
@@ -1833,7 +2478,7 @@ function parseSideQuestionContent(content) {
1833
2478
  } else if (Array.isArray(content)) {
1834
2479
  const parts = [];
1835
2480
  for (const part of content) {
1836
- if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return null;
2481
+ if (!isRecord2(part) || part.type !== "text" || typeof part.text !== "string") return null;
1837
2482
  parts.push(part.text);
1838
2483
  }
1839
2484
  text = parts.join("\n");
@@ -1852,7 +2497,7 @@ function assistantText(content) {
1852
2497
  if (!Array.isArray(content)) return "";
1853
2498
  const parts = [];
1854
2499
  for (const part of content) {
1855
- if (isRecord(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text);
2500
+ if (isRecord2(part) && part.type === "text" && typeof part.text === "string") parts.push(part.text);
1856
2501
  }
1857
2502
  return parts.join("\n").trim();
1858
2503
  }
@@ -1882,9 +2527,9 @@ function dispatchSideQuestionResponse(activeProcess, line) {
1882
2527
  } catch {
1883
2528
  return false;
1884
2529
  }
1885
- if (!isRecord(message) || message.type !== "control_response") return false;
2530
+ if (!isRecord2(message) || message.type !== "control_response") return false;
1886
2531
  const response = message.response;
1887
- if (!isRecord(response) || typeof response.request_id !== "string") return false;
2532
+ if (!isRecord2(response) || typeof response.request_id !== "string") return false;
1888
2533
  return activeProcess.lineEmitter.emit(`side-question:${response.request_id}`, response);
1889
2534
  }
1890
2535
  async function requestSideQuestion(activeProcess, question, options) {
@@ -1969,7 +2614,7 @@ async function requestSideQuestion(activeProcess, question, options) {
1969
2614
  return;
1970
2615
  }
1971
2616
  const result = response.response;
1972
- if (response.subtype !== "success" || !isRecord(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
2617
+ if (response.subtype !== "success" || !isRecord2(result) || typeof result.response !== "string" || typeof result.synthetic !== "boolean") {
1973
2618
  fail(new Error("Claude Code returned an invalid /btw response."));
1974
2619
  return;
1975
2620
  }
@@ -2304,11 +2949,31 @@ async function deleteActiveProcessAndWait(key, options = {}) {
2304
2949
  function getClaudeSessionId(key) {
2305
2950
  return claudeSessions.get(key);
2306
2951
  }
2952
+ var MAX_CLAUDE_SESSION_ENTRIES = 64;
2953
+ function claudeSessionIsBusy(key) {
2954
+ if (activeProcesses.has(key)) return true;
2955
+ return getPendingProxyCalls(key).length > 0 || hasExitPlanModeQuestions(key);
2956
+ }
2957
+ function capClaudeSessions() {
2958
+ for (const key of [...claudeSessions.keys()]) {
2959
+ if (claudeSessions.size <= MAX_CLAUDE_SESSION_ENTRIES) return;
2960
+ if (claudeSessionIsBusy(key)) continue;
2961
+ log.info("claude session cap reached; releasing an idle session", {
2962
+ sessionKey: key,
2963
+ size: claudeSessions.size,
2964
+ cap: MAX_CLAUDE_SESSION_ENTRIES
2965
+ });
2966
+ deleteClaudeSessionId(key);
2967
+ }
2968
+ }
2307
2969
  function setClaudeSessionId(key, sessionId) {
2970
+ claudeSessions.delete(key);
2308
2971
  claudeSessions.set(key, sessionId);
2972
+ capClaudeSessions();
2309
2973
  }
2310
2974
  function deleteClaudeSessionId(key) {
2311
2975
  clearExitPlanModeQuestions(key);
2976
+ clearAccountFailoverQuestions(key);
2312
2977
  const claudeSessionId = claudeSessions.get(key);
2313
2978
  if (claudeSessionId) clearLedger(claudeSessionId);
2314
2979
  claudeSessions.delete(key);
@@ -2365,6 +3030,7 @@ function spawnClaudeProcess(cliPath, cliArgs, cwd, sessionKey2, proxyServer, mcp
2365
3030
  systemPromptFile,
2366
3031
  effort,
2367
3032
  startedAt: Date.now(),
3033
+ cliPath,
2368
3034
  cliArgs: [...cliArgs],
2369
3035
  unattendedLines: [],
2370
3036
  unattendedDropped: 0
@@ -2841,273 +3507,40 @@ async function handleBtwCommand(client, input, options = {}) {
2841
3507
  }
2842
3508
  if (!busy) return;
2843
3509
  const started = Date.now();
2844
- const outcome = await Promise.race([
2845
- inlineDelivered,
2846
- waitForSessionIdle(client, input.sessionID, { ...options, stop: () => inlineDone }).then(
2847
- (idle) => idle ? "idle" : "timeout"
2848
- )
2849
- ]);
2850
- if (outcome === "inline") {
2851
- log.info("btw: answered inside the running turn, dropping the /btw message", {
2852
- sessionID: input.sessionID,
2853
- waitedMs: Date.now() - started
2854
- });
2855
- throw new BtwHandledError(BTW_INLINE_HANDLED_MESSAGE);
2856
- }
2857
- log.info("btw: turn over, releasing the /btw message", {
2858
- sessionID: input.sessionID,
2859
- idle: outcome === "idle",
2860
- waitedMs: Date.now() - started
2861
- });
2862
- if (outcome === "timeout") {
2863
- showToast(client, { title: "btw", message: BTW_TURN_TOO_LONG_MESSAGE, variant: "warning", duration: 8e3 });
2864
- throw new BtwHandledError(BTW_TURN_TOO_LONG_MESSAGE);
2865
- }
2866
- }
2867
-
2868
- // src/cli-events.ts
2869
- function isRecord2(value) {
2870
- return value !== null && typeof value === "object" && !Array.isArray(value);
2871
- }
2872
- function str(value) {
2873
- return typeof value === "string" && value.length > 0 ? value : void 0;
2874
- }
2875
- function num(value) {
2876
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
2877
- }
2878
- var RATE_LIMIT_MARKER = "\u258C **rate limit:**";
2879
- var RATE_LIMIT_ACTION = "Enable extra usage or add credits on the account, wait for the window to reset, switch account, org or plan, or authenticate with an API key.";
2880
- var OVERAGE_DISABLED_REASONS = {
2881
- overage_not_provisioned: "extra usage is not set up on this account",
2882
- org_level_disabled: "extra usage is disabled for your organization",
2883
- org_level_disabled_until: "extra usage is disabled for your organization for now",
2884
- out_of_credits: "the account's usage credits are spent",
2885
- seat_tier_level_disabled: "your seat tier does not allow extra usage",
2886
- member_level_disabled: "extra usage is disabled for your member account",
2887
- seat_tier_zero_credit_limit: "your seat tier has a zero credit limit",
2888
- group_zero_credit_limit: "your group has a zero credit limit",
2889
- member_zero_credit_limit: "your member account has a zero credit limit",
2890
- org_service_level_disabled: "your organization's service level does not include extra usage",
2891
- no_limits_configured: "no usage limits are configured for this account",
2892
- fetch_error: "the CLI could not read the account's usage limits"
2893
- };
2894
- var RATE_LIMIT_WINDOWS = {
2895
- five_hour: "the 5-hour window",
2896
- seven_day: "the 7-day window",
2897
- seven_day_opus: "the 7-day Opus window",
2898
- seven_day_sonnet: "the 7-day Sonnet window",
2899
- seven_day_overage_included: "the 7-day extra-usage window",
2900
- overage: "extra usage"
2901
- };
2902
- function parseRateLimitEvent(msg) {
2903
- if (msg.type !== "rate_limit_event") return null;
2904
- const info = msg.rate_limit_info;
2905
- if (!isRecord2(info)) return null;
2906
- return {
2907
- status: str(info.status),
2908
- rateLimitType: str(info.rateLimitType),
2909
- resetsAt: num(info.resetsAt),
2910
- utilization: num(info.utilization),
2911
- isUsingOverage: info.isUsingOverage === true,
2912
- overageStatus: str(info.overageStatus),
2913
- overageResetsAt: num(info.overageResetsAt),
2914
- overageDisabledReason: str(info.overageDisabledReason)
2915
- };
2916
- }
2917
- function formatResetsAt(resetsAt) {
2918
- if (resetsAt === void 0) return void 0;
2919
- const ms = resetsAt < 1e12 ? resetsAt * 1e3 : resetsAt;
2920
- const date = new Date(ms);
2921
- return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
2922
- }
2923
- function rateLimitKey(info) {
2924
- return [
2925
- info.status ?? "?",
2926
- info.rateLimitType ?? "?",
2927
- info.overageStatus ?? "?",
2928
- info.overageDisabledReason ?? "?"
2929
- ].join("|");
2930
- }
2931
- function isRateLimitRejected(info) {
2932
- return info.status === "rejected" || info.overageStatus === "rejected";
2933
- }
2934
- function describeRateLimit(info) {
2935
- if (!info.status && !info.overageStatus) return null;
2936
- const window = info.rateLimitType ? RATE_LIMIT_WINDOWS[info.rateLimitType] ?? info.rateLimitType : void 0;
2937
- const resets = formatResetsAt(info.resetsAt);
2938
- const overageResets = formatResetsAt(info.overageResetsAt);
2939
- const key = rateLimitKey(info);
2940
- if (isRateLimitRejected(info)) {
2941
- const parts = [];
2942
- parts.push(
2943
- info.status === "rejected" ? `Claude Code rejected this request: you are out of usage${window ? ` in ${window}` : ""}.` : "Claude Code rejected this request: paid extra usage is not available on this account."
2944
- );
2945
- const reason = info.overageDisabledReason ? OVERAGE_DISABLED_REASONS[info.overageDisabledReason] ?? info.overageDisabledReason : void 0;
2946
- if (reason) parts.push(`Extra usage is unavailable because ${reason}.`);
2947
- const resetAt = resets ?? overageResets;
2948
- if (resetAt) parts.push(`Resets at ${resetAt}.`);
2949
- parts.push(RATE_LIMIT_ACTION);
2950
- const message = parts.join(" ");
2951
- return { key, level: "warn", message, transcript: `
2952
- ${RATE_LIMIT_MARKER} ${message}
2953
- ` };
2954
- }
2955
- if (info.status === "allowed_warning" || info.overageStatus === "allowed_warning") {
2956
- const used = info.utilization === void 0 ? "" : ` (${Math.round(info.utilization * 100)}% used)`;
2957
- return {
2958
- key,
2959
- level: "notice",
2960
- message: `Approaching the usage limit${window ? ` for ${window}` : ""}${used}${resets ? `, resets at ${resets}` : ""}.`,
2961
- transcript: null
2962
- };
2963
- }
2964
- return {
2965
- key,
2966
- level: "info",
2967
- message: `usage limits updated${window ? ` for ${window}` : ""}`,
2968
- transcript: null
2969
- };
2970
- }
2971
- var reportedRateLimits = /* @__PURE__ */ new Set();
2972
- function reportRateLimitEvent(msg) {
2973
- const info = parseRateLimitEvent(msg);
2974
- if (!info) return null;
2975
- const report = describeRateLimit(info);
2976
- if (!report) return null;
2977
- const data = { ...info };
2978
- if (reportedRateLimits.has(report.key)) {
2979
- log.debug(report.message, data);
2980
- return null;
2981
- }
2982
- reportedRateLimits.add(report.key);
2983
- log[report.level](report.message, data);
2984
- return report.transcript;
2985
- }
2986
- var API_KEY_SOURCES = /* @__PURE__ */ new Set([
2987
- "ANTHROPIC_API_KEY",
2988
- "apiKeyHelper",
2989
- "/login managed key",
2990
- "user",
2991
- "project",
2992
- "org",
2993
- "temporary"
2994
- ]);
2995
- function parseSystemInit(msg) {
2996
- if (msg.type !== "system" || msg.subtype !== "init") return null;
2997
- const raw = msg;
2998
- const servers = [];
2999
- if (Array.isArray(raw.mcp_servers)) {
3000
- for (const entry of raw.mcp_servers) {
3001
- if (!isRecord2(entry)) continue;
3002
- servers.push({ name: str(entry.name) ?? "unknown", status: str(entry.status) ?? "unknown" });
3003
- }
3004
- }
3005
- return {
3006
- apiKeySource: str(raw.apiKeySource),
3007
- permissionMode: str(raw.permissionMode),
3008
- model: str(raw.model),
3009
- cliVersion: str(raw.claude_code_version),
3010
- toolCount: Array.isArray(raw.tools) ? raw.tools.length : 0,
3011
- mcpServers: servers
3012
- };
3013
- }
3014
- function apiKeySourceWarning(apiKeySource, ignoreAnthropicApiKey) {
3015
- if (!apiKeySource || !API_KEY_SOURCES.has(apiKeySource)) return null;
3016
- const base = `Claude Code authenticated with an API key (apiKeySource: ${apiKeySource}), so these turns bill as pay-as-you-go API usage instead of your subscription's Agent SDK credit.`;
3017
- return ignoreAnthropicApiKey ? `${base} \`ignoreAnthropicApiKey\` is already on, so the key is not coming from the environment: check the CLI's own settings (\`claude config\`) or an \`apiKeyHelper\`.` : `${base} Set the provider option \`ignoreAnthropicApiKey: true\` to strip the key from spawns and fall back to the stored subscription auth.`;
3018
- }
3019
- var warnedApiKeySources = /* @__PURE__ */ new Set();
3020
- var warnedMcpFailures = /* @__PURE__ */ new Set();
3021
- function reportSystemInit(msg, options = {}) {
3022
- const info = parseSystemInit(msg);
3023
- if (!info) return;
3024
- log.info("claude session init", {
3025
- apiKeySource: info.apiKeySource ?? null,
3026
- permissionMode: info.permissionMode ?? null,
3027
- model: info.model ?? null,
3028
- cliVersion: info.cliVersion ?? null,
3029
- tools: info.toolCount,
3030
- mcpServers: info.mcpServers
3031
- });
3032
- for (const server2 of info.mcpServers) {
3033
- if (server2.status === "connected") continue;
3034
- const key2 = `${server2.name}:${server2.status}`;
3035
- const message = `MCP server "${server2.name}" is ${server2.status} in Claude Code; its tools are not available to the model this session.`;
3036
- if (warnedMcpFailures.has(key2)) {
3037
- log.debug(message, { server: server2.name, status: server2.status });
3038
- continue;
3039
- }
3040
- warnedMcpFailures.add(key2);
3041
- log.warn(message, { server: server2.name, status: server2.status });
3042
- }
3043
- const apiKeyMessage = apiKeySourceWarning(info.apiKeySource, options.ignoreAnthropicApiKey);
3044
- if (!apiKeyMessage) return;
3045
- const key = `${info.apiKeySource}:${options.ignoreAnthropicApiKey ? "ignored" : "passed"}`;
3046
- if (warnedApiKeySources.has(key)) {
3047
- log.debug(apiKeyMessage, { apiKeySource: info.apiKeySource });
3048
- return;
3049
- }
3050
- warnedApiKeySources.add(key);
3051
- log.warn(apiKeyMessage, { apiKeySource: info.apiKeySource });
3052
- }
3053
- var COMPACT_BOUNDARY_MARKER = "\u258C **context compacted:**";
3054
- function parseCompactBoundary(msg) {
3055
- if (msg.type !== "system" || msg.subtype !== "compact_boundary") return null;
3056
- const raw = msg;
3057
- const meta = isRecord2(raw.compact_metadata) ? raw.compact_metadata : isRecord2(raw.compactMetadata) ? raw.compactMetadata : void 0;
3058
- return {
3059
- trigger: str(meta?.trigger) ?? "unknown",
3060
- preTokens: num(meta?.pre_tokens) ?? num(meta?.preTokens),
3061
- postTokens: num(meta?.post_tokens) ?? num(meta?.postTokens)
3062
- };
3063
- }
3064
- function formatCompactBoundaryNote(boundary) {
3065
- const how = boundary.trigger === "auto" ? "on its own" : `on a ${boundary.trigger} request`;
3066
- const sizes = boundary.preTokens !== void 0 && boundary.postTokens !== void 0 ? ` (${boundary.preTokens.toLocaleString("en-US")} tokens to ${boundary.postTokens.toLocaleString("en-US")})` : "";
3067
- return `
3068
- ${COMPACT_BOUNDARY_MARKER} Claude Code compacted its own context ${how}${sizes}. Earlier detail in this conversation is now a summary.
3069
- `;
3070
- }
3071
- function reportCompactBoundary(msg) {
3072
- const boundary = parseCompactBoundary(msg);
3073
- if (!boundary) return null;
3074
- log.notice("claude code compacted its own context", {
3075
- trigger: boundary.trigger,
3076
- preTokens: boundary.preTokens ?? null,
3077
- postTokens: boundary.postTokens ?? null
3510
+ const outcome = await Promise.race([
3511
+ inlineDelivered,
3512
+ waitForSessionIdle(client, input.sessionID, { ...options, stop: () => inlineDone }).then(
3513
+ (idle) => idle ? "idle" : "timeout"
3514
+ )
3515
+ ]);
3516
+ if (outcome === "inline") {
3517
+ log.info("btw: answered inside the running turn, dropping the /btw message", {
3518
+ sessionID: input.sessionID,
3519
+ waitedMs: Date.now() - started
3520
+ });
3521
+ throw new BtwHandledError(BTW_INLINE_HANDLED_MESSAGE);
3522
+ }
3523
+ log.info("btw: turn over, releasing the /btw message", {
3524
+ sessionID: input.sessionID,
3525
+ idle: outcome === "idle",
3526
+ waitedMs: Date.now() - started
3078
3527
  });
3079
- return formatCompactBoundaryNote(boundary);
3080
- }
3081
- var RESULT_ERROR_MARKER = "\u258C **claude code error:**";
3082
- var RESULT_SUBTYPES = {
3083
- error_max_turns: "it hit its internal turn limit before finishing",
3084
- error_during_execution: "it failed while running the turn",
3085
- error_max_budget_usd: "it hit the configured spend limit for the turn",
3086
- error_max_structured_output_retries: "it could not produce valid structured output"
3087
- };
3088
- function describeResultFailure(msg) {
3089
- if (msg.type !== "result") return null;
3090
- const subtype = msg.subtype;
3091
- if (!subtype || subtype === "success") return null;
3092
- const explanation = RESULT_SUBTYPES[subtype];
3093
- return explanation ? `Claude Code ended the turn with \`${subtype}\`: ${explanation}.` : `Claude Code ended the turn with \`${subtype}\`.`;
3094
- }
3095
- function formatResultFailureNote(message) {
3096
- return `
3097
- ${RESULT_ERROR_MARKER} ${message}
3098
- `;
3528
+ if (outcome === "timeout") {
3529
+ showToast(client, { title: "btw", message: BTW_TURN_TOO_LONG_MESSAGE, variant: "warning", duration: 8e3 });
3530
+ throw new BtwHandledError(BTW_TURN_TOO_LONG_MESSAGE);
3531
+ }
3099
3532
  }
3100
3533
 
3101
3534
  // src/startup-diagnostics.ts
3102
3535
  import { execFile as execFile2 } from "child_process";
3103
3536
  import * as fs4 from "fs";
3104
- import * as path4 from "path";
3537
+ import * as path5 from "path";
3105
3538
  import { promisify as promisify2 } from "util";
3106
3539
  import { fileURLToPath } from "url";
3107
3540
 
3108
3541
  // src/mcp-bridge.ts
3109
3542
  import * as fs3 from "fs";
3110
- import * as path3 from "path";
3543
+ import * as path4 from "path";
3111
3544
  import * as os2 from "os";
3112
3545
  import * as crypto2 from "crypto";
3113
3546
  import {
@@ -3168,14 +3601,14 @@ function deepMerge(target, source) {
3168
3601
  }
3169
3602
  function walkUp(opts) {
3170
3603
  const out = [];
3171
- let current = path3.resolve(opts.start);
3604
+ let current = path4.resolve(opts.start);
3172
3605
  while (true) {
3173
3606
  for (const target of opts.targets) {
3174
- const candidate = path3.join(current, target);
3607
+ const candidate = path4.join(current, target);
3175
3608
  if (opts.predicate(candidate)) out.push(candidate);
3176
3609
  }
3177
- if (opts.stop && current === path3.resolve(opts.stop)) break;
3178
- const parent = path3.dirname(current);
3610
+ if (opts.stop && current === path4.resolve(opts.stop)) break;
3611
+ const parent = path4.dirname(current);
3179
3612
  if (parent === current) break;
3180
3613
  current = parent;
3181
3614
  }
@@ -3183,28 +3616,28 @@ function walkUp(opts) {
3183
3616
  }
3184
3617
  function detectWorktree(cwd) {
3185
3618
  const override = process.env.OPENCODE_WORKTREE;
3186
- if (override) return path3.resolve(override);
3187
- let current = path3.resolve(cwd);
3619
+ if (override) return path4.resolve(override);
3620
+ let current = path4.resolve(cwd);
3188
3621
  while (true) {
3189
- const gitPath = path3.join(current, ".git");
3622
+ const gitPath = path4.join(current, ".git");
3190
3623
  try {
3191
3624
  if (fs3.existsSync(gitPath)) return current;
3192
3625
  } catch {
3193
3626
  }
3194
- const parent = path3.dirname(current);
3627
+ const parent = path4.dirname(current);
3195
3628
  if (parent === current) return void 0;
3196
3629
  current = parent;
3197
3630
  }
3198
3631
  }
3199
3632
  function globalConfigDir() {
3200
- const xdg = process.env.XDG_CONFIG_HOME ?? path3.join(os2.homedir(), ".config");
3201
- return path3.join(xdg, "opencode");
3633
+ const xdg = process.env.XDG_CONFIG_HOME ?? path4.join(os2.homedir(), ".config");
3634
+ return path4.join(xdg, "opencode");
3202
3635
  }
3203
3636
  function loadGlobalConfig() {
3204
3637
  const dir = globalConfigDir();
3205
3638
  let merged = {};
3206
3639
  for (const name of FILE_NAMES.slice().reverse()) {
3207
- const file = path3.join(dir, name);
3640
+ const file = path4.join(dir, name);
3208
3641
  if (!fileExists(file)) continue;
3209
3642
  const parsed = readAndParse(file);
3210
3643
  if (parsed) merged = deepMerge(merged, parsed);
@@ -3214,7 +3647,7 @@ function loadGlobalConfig() {
3214
3647
  function loadProjectFilesInDir(dir) {
3215
3648
  let merged = {};
3216
3649
  for (const name of PROJECT_FILE_NAMES) {
3217
- const file = path3.join(dir, name);
3650
+ const file = path4.join(dir, name);
3218
3651
  if (!fileExists(file)) continue;
3219
3652
  const parsed = readAndParse(file);
3220
3653
  if (parsed) merged = deepMerge(merged, parsed);
@@ -3225,7 +3658,7 @@ function dotOpencodeDirs(cwd, worktree) {
3225
3658
  const dirs = [];
3226
3659
  const seen = /* @__PURE__ */ new Set();
3227
3660
  const push = (p) => {
3228
- const abs = path3.resolve(p);
3661
+ const abs = path4.resolve(p);
3229
3662
  if (!seen.has(abs) && dirExists(abs)) {
3230
3663
  seen.add(abs);
3231
3664
  dirs.push(abs);
@@ -3241,7 +3674,7 @@ function dotOpencodeDirs(cwd, worktree) {
3241
3674
  }
3242
3675
  const home = os2.homedir();
3243
3676
  if (home) {
3244
- const homeDot = path3.join(home, ".opencode");
3677
+ const homeDot = path4.join(home, ".opencode");
3245
3678
  if (dirExists(homeDot)) push(homeDot);
3246
3679
  }
3247
3680
  const envDir = process.env.OPENCODE_CONFIG_DIR;
@@ -3366,7 +3799,7 @@ function mergeOpencodeMcp(cwd, runtimeStatus) {
3366
3799
  const projectDirs = [];
3367
3800
  const seenProjectDirs = /* @__PURE__ */ new Set();
3368
3801
  for (const f of projectFiles) {
3369
- const d = path3.dirname(f);
3802
+ const d = path4.dirname(f);
3370
3803
  if (!seenProjectDirs.has(d)) {
3371
3804
  seenProjectDirs.add(d);
3372
3805
  projectDirs.push(d);
@@ -3412,7 +3845,7 @@ function finishBridge(input) {
3412
3845
  }
3413
3846
  const body = JSON.stringify({ mcpServers: servers }, null, 2);
3414
3847
  const bodyDigest = crypto2.createHash("sha256").update(body).digest("hex").slice(0, 12);
3415
- const outPath = path3.join(
3848
+ const outPath = path4.join(
3416
3849
  pluginTmpDir(),
3417
3850
  `mcp-${bodyDigest}.json`
3418
3851
  );
@@ -3494,6 +3927,24 @@ async function fetchSessionDirectory(sessionID) {
3494
3927
  return void 0;
3495
3928
  }
3496
3929
  }
3930
+ async function fetchSessionParentId(sessionID) {
3931
+ if (!sessionID || sessionID === "default") return void 0;
3932
+ const client = opencodeClient;
3933
+ if (!client?.session?.get) return void 0;
3934
+ try {
3935
+ const res = await client.session.get({ path: { id: sessionID } });
3936
+ const data = res.data;
3937
+ if (!data || typeof data !== "object") return void 0;
3938
+ const parentID = data.parentID;
3939
+ return typeof parentID === "string" && parentID.length > 0 ? parentID : void 0;
3940
+ } catch (err) {
3941
+ log.warn("failed to fetch opencode session parent", {
3942
+ sessionID,
3943
+ error: err instanceof Error ? err.message : String(err)
3944
+ });
3945
+ return void 0;
3946
+ }
3947
+ }
3497
3948
  async function getRuntimeMcpStatus() {
3498
3949
  const client = opencodeClient;
3499
3950
  if (!client?.mcp?.status) return void 0;
@@ -3551,8 +4002,8 @@ var cachedPluginVersion;
3551
4002
  function pluginVersion() {
3552
4003
  if (cachedPluginVersion) return cachedPluginVersion;
3553
4004
  try {
3554
- const here = path4.dirname(fileURLToPath(import.meta.url));
3555
- const raw = fs4.readFileSync(path4.join(here, "..", "package.json"), "utf8");
4005
+ const here = path5.dirname(fileURLToPath(import.meta.url));
4006
+ const raw = fs4.readFileSync(path5.join(here, "..", "package.json"), "utf8");
3556
4007
  const version = JSON.parse(raw).version;
3557
4008
  cachedPluginVersion = typeof version === "string" ? version : "unknown";
3558
4009
  } catch {
@@ -3576,7 +4027,7 @@ var opencodeVersionProbe;
3576
4027
  function detectOpencodeVersion(execPath = process.execPath) {
3577
4028
  if (opencodeVersionProbe) return opencodeVersionProbe;
3578
4029
  opencodeVersionProbe = (async () => {
3579
- if (!path4.basename(execPath).toLowerCase().includes("opencode")) {
4030
+ if (!path5.basename(execPath).toLowerCase().includes("opencode")) {
3580
4031
  log.debug("skipping opencode version probe: execPath is not opencode", { execPath });
3581
4032
  return void 0;
3582
4033
  }
@@ -3970,7 +4421,9 @@ var PLUGIN_NOTE_MARKERS = [
3970
4421
  COMPACT_BOUNDARY_MARKER,
3971
4422
  RATE_LIMIT_MARKER,
3972
4423
  RESULT_ERROR_MARKER,
3973
- DOCTOR_MARKER
4424
+ DOCTOR_MARKER,
4425
+ STREAM_TIMEOUT_MARKER,
4426
+ FAILOVER_MARKER
3974
4427
  ];
3975
4428
  function isPluginNote(part) {
3976
4429
  if (!part || part.type !== "text" || typeof part.text !== "string") return false;
@@ -3983,6 +4436,7 @@ function stripPluginNotes(content) {
3983
4436
  return kept.length === content.length ? content : kept;
3984
4437
  }
3985
4438
  function filterSideQuestionHistory(prompt) {
4439
+ prompt = stripAccountFailoverParts(prompt);
3986
4440
  let pluginCommand = false;
3987
4441
  const kept = prompt.filter((message) => {
3988
4442
  if (message.role === "user") {
@@ -4355,7 +4809,7 @@ Now continuing with the current message:
4355
4809
 
4356
4810
  // src/agent-models.ts
4357
4811
  import { readFile, readdir } from "fs/promises";
4358
- import path5 from "path";
4812
+ import path6 from "path";
4359
4813
 
4360
4814
  // src/models.ts
4361
4815
  var PROVIDER_ID = "claude-code";
@@ -4733,11 +5187,11 @@ async function readAgentMarkdownRecords(directories) {
4733
5187
  const name = entry.slice(0, -3);
4734
5188
  if (records[name]) continue;
4735
5189
  try {
4736
- const text = await readFile(path5.join(directory, entry), "utf8");
5190
+ const text = await readFile(path6.join(directory, entry), "utf8");
4737
5191
  records[name] = parseAgentFrontmatter(text);
4738
5192
  } catch (err) {
4739
5193
  log.debug("failed to read agent markdown", {
4740
- file: path5.join(directory, entry),
5194
+ file: path6.join(directory, entry),
4741
5195
  error: String(err)
4742
5196
  });
4743
5197
  }
@@ -4749,12 +5203,12 @@ function agentDirectories(home, projectDirectory) {
4749
5203
  const directories = [];
4750
5204
  if (projectDirectory) {
4751
5205
  for (const name of AGENT_DIR_NAMES) {
4752
- directories.push(path5.join(projectDirectory, ".opencode", name));
5206
+ directories.push(path6.join(projectDirectory, ".opencode", name));
4753
5207
  }
4754
5208
  }
4755
5209
  if (home) {
4756
5210
  for (const name of AGENT_DIR_NAMES) {
4757
- directories.push(path5.join(home, ".config", "opencode", name));
5211
+ directories.push(path6.join(home, ".config", "opencode", name));
4758
5212
  }
4759
5213
  }
4760
5214
  return directories;
@@ -4764,13 +5218,13 @@ function agentDirectories(home, projectDirectory) {
4764
5218
  import * as crypto3 from "crypto";
4765
5219
  import * as fs5 from "fs";
4766
5220
  import * as os3 from "os";
4767
- import * as path6 from "path";
5221
+ import * as path7 from "path";
4768
5222
  import { fileURLToPath as fileURLToPath2 } from "url";
4769
5223
  var SKILL_PLUGIN_NAME = "opencode-skills";
4770
5224
  function bundledSkillsDir() {
4771
5225
  try {
4772
5226
  const here = fileURLToPath2(import.meta.url);
4773
- const dir = path6.resolve(path6.dirname(here), "..", "skills");
5227
+ const dir = path7.resolve(path7.dirname(here), "..", "skills");
4774
5228
  return dirExists2(dir) ? dir : null;
4775
5229
  } catch {
4776
5230
  return null;
@@ -4794,24 +5248,24 @@ function skillRoots(cwd) {
4794
5248
  const roots = [];
4795
5249
  const seen = /* @__PURE__ */ new Set();
4796
5250
  const push = (p) => {
4797
- const abs = path6.resolve(p);
5251
+ const abs = path7.resolve(p);
4798
5252
  if (seen.has(abs)) return;
4799
5253
  seen.add(abs);
4800
5254
  if (dirExists2(abs)) roots.push(abs);
4801
5255
  };
4802
- let current = path6.resolve(cwd);
5256
+ let current = path7.resolve(cwd);
4803
5257
  while (true) {
4804
- push(path6.join(current, ".opencode", "skills"));
4805
- const parent = path6.dirname(current);
5258
+ push(path7.join(current, ".opencode", "skills"));
5259
+ const parent = path7.dirname(current);
4806
5260
  if (parent === current) break;
4807
5261
  current = parent;
4808
5262
  }
4809
5263
  const home = os3.homedir();
4810
- if (home) push(path6.join(home, ".opencode", "skills"));
5264
+ if (home) push(path7.join(home, ".opencode", "skills"));
4811
5265
  const envDir = process.env.OPENCODE_CONFIG_DIR;
4812
- if (envDir) push(path6.join(envDir, "skills"));
4813
- const xdg = process.env.XDG_CONFIG_HOME ?? (home ? path6.join(home, ".config") : null);
4814
- if (xdg) push(path6.join(xdg, "opencode", "skills"));
5266
+ if (envDir) push(path7.join(envDir, "skills"));
5267
+ const xdg = process.env.XDG_CONFIG_HOME ?? (home ? path7.join(home, ".config") : null);
5268
+ if (xdg) push(path7.join(xdg, "opencode", "skills"));
4815
5269
  return roots;
4816
5270
  }
4817
5271
  function collectSkills(root, claimed, found) {
@@ -4826,8 +5280,8 @@ function collectSkills(root, claimed, found) {
4826
5280
  const name = entry.name;
4827
5281
  if (name.startsWith(".")) continue;
4828
5282
  if (claimed.has(name)) continue;
4829
- const dir = path6.join(root, name);
4830
- if (!fileExists2(path6.join(dir, "SKILL.md"))) continue;
5283
+ const dir = path7.join(root, name);
5284
+ if (!fileExists2(path7.join(dir, "SKILL.md"))) continue;
4831
5285
  claimed.add(name);
4832
5286
  found.push({ name, dir });
4833
5287
  }
@@ -4851,7 +5305,7 @@ function registerBundledSkillPath(config) {
4851
5305
  if (!dir) return false;
4852
5306
  config.skills ??= {};
4853
5307
  const paths = config.skills.paths ??= [];
4854
- if (paths.some((p) => path6.resolve(p) === dir)) return false;
5308
+ if (paths.some((p) => path7.resolve(p) === dir)) return false;
4855
5309
  paths.push(dir);
4856
5310
  return true;
4857
5311
  }
@@ -4867,13 +5321,13 @@ function buildSkillPluginDir(skills) {
4867
5321
  if (skills.length === 0) return null;
4868
5322
  const fingerprint = skills.map((s) => `${s.name}\0${s.dir}`).join("\n");
4869
5323
  const hash = crypto3.createHash("sha256").update(fingerprint).digest("hex").slice(0, 12);
4870
- const root = path6.join(pluginTmpDir(), `skills-${hash}`);
4871
- const manifest = path6.join(root, ".claude-plugin", "plugin.json");
5324
+ const root = path7.join(pluginTmpDir(), `skills-${hash}`);
5325
+ const manifest = path7.join(root, ".claude-plugin", "plugin.json");
4872
5326
  if (fileExists2(manifest)) return root;
4873
5327
  try {
4874
5328
  fs5.rmSync(root, { recursive: true, force: true });
4875
- fs5.mkdirSync(path6.join(root, ".claude-plugin"), { recursive: true });
4876
- fs5.mkdirSync(path6.join(root, "skills"), { recursive: true });
5329
+ fs5.mkdirSync(path7.join(root, ".claude-plugin"), { recursive: true });
5330
+ fs5.mkdirSync(path7.join(root, "skills"), { recursive: true });
4877
5331
  fs5.writeFileSync(
4878
5332
  manifest,
4879
5333
  JSON.stringify(
@@ -4887,7 +5341,7 @@ function buildSkillPluginDir(skills) {
4887
5341
  { encoding: "utf8", mode: 384 }
4888
5342
  );
4889
5343
  for (const skill of skills) {
4890
- linkSkill(skill.dir, path6.join(root, "skills", skill.name));
5344
+ linkSkill(skill.dir, path7.join(root, "skills", skill.name));
4891
5345
  }
4892
5346
  } catch (err) {
4893
5347
  log.warn("failed to stage opencode skill plugin dir", {
@@ -4930,11 +5384,11 @@ import { unlink as unlink2 } from "fs/promises";
4930
5384
  // src/claude-session-bun.ts
4931
5385
  import * as os4 from "os";
4932
5386
  import * as fs6 from "fs";
4933
- import * as path7 from "path";
5387
+ import * as path8 from "path";
4934
5388
  import { execFileSync } from "child_process";
4935
5389
  import { randomUUID as randomUUID4 } from "crypto";
4936
5390
  function resolveClaude(cmd = "claude") {
4937
- if (path7.isAbsolute(cmd) && fs6.existsSync(cmd)) return cmd;
5391
+ if (path8.isAbsolute(cmd) && fs6.existsSync(cmd)) return cmd;
4938
5392
  const viaBun = Bun.which(cmd);
4939
5393
  if (viaBun) return viaBun;
4940
5394
  const isWin = os4.platform() === "win32";
@@ -4950,7 +5404,7 @@ function resolveClaude(cmd = "claude") {
4950
5404
  throw new Error(`Could not resolve command on PATH: ${cmd}`);
4951
5405
  }
4952
5406
  function encodeCwd(cwd) {
4953
- return path7.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
5407
+ return path8.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-");
4954
5408
  }
4955
5409
  function interactiveSpawnEnv(opts) {
4956
5410
  return {
@@ -4969,12 +5423,12 @@ var TERMINAL_STOP = /* @__PURE__ */ new Set(["end_turn", "stop_sequence", "max_t
4969
5423
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
4970
5424
  function resolveConfigDir(configDir) {
4971
5425
  const value = configDir ?? process.env.CLAUDE_CONFIG_DIR;
4972
- if (!value) return path7.join(os4.homedir(), ".claude");
5426
+ if (!value) return path8.join(os4.homedir(), ".claude");
4973
5427
  if (value === "~") return os4.homedir();
4974
5428
  if (value.startsWith("~/") || value.startsWith("~\\")) {
4975
- return path7.join(os4.homedir(), value.slice(2));
5429
+ return path8.join(os4.homedir(), value.slice(2));
4976
5430
  }
4977
- return path7.resolve(value);
5431
+ return path8.resolve(value);
4978
5432
  }
4979
5433
  var ClaudeSession = class {
4980
5434
  sessionId;
@@ -4992,11 +5446,11 @@ var ClaudeSession = class {
4992
5446
  signal;
4993
5447
  o;
4994
5448
  constructor(opts = {}) {
4995
- this.cwd = path7.resolve(opts.cwd ?? process.cwd());
5449
+ this.cwd = path8.resolve(opts.cwd ?? process.cwd());
4996
5450
  this.configDir = resolveConfigDir(opts.configDir);
4997
5451
  this.signal = opts.signal;
4998
5452
  this.sessionId = randomUUID4();
4999
- this.jsonlPath = path7.join(
5453
+ this.jsonlPath = path8.join(
5000
5454
  this.configDir,
5001
5455
  "projects",
5002
5456
  encodeCwd(this.cwd),
@@ -6580,9 +7034,16 @@ var ClaudeCodeLanguageModel = class {
6580
7034
  cliToolCallIds: /* @__PURE__ */ new Set(),
6581
7035
  stripContextReminders: this.stripContextRemindersEnabled()
6582
7036
  });
7037
+ const failover = await resolveFailoverSpawn({
7038
+ account: this.config.account ?? DEFAULT_ACCOUNT,
7039
+ baseCliPath: this.config.baseCliPath ?? this.config.cliPath,
7040
+ cliPath: this.config.cliPath,
7041
+ modelId: effectiveModelId
7042
+ });
7043
+ const cliPath = failover.cliPath;
6583
7044
  const [runtimeStatus, cliVersion, planModeQuestionActive] = await Promise.all([
6584
7045
  getRuntimeMcpStatus(),
6585
- detectCliVersion(this.config.cliPath),
7046
+ detectCliVersion(cliPath),
6586
7047
  this.resolvePlanModeQuestion(compactionMode)
6587
7048
  ]);
6588
7049
  const systemPromptFile = buildAppendedSystemPrompt(
@@ -6593,10 +7054,10 @@ var ClaudeCodeLanguageModel = class {
6593
7054
  // An existing summary still carries: it is this key's prior context.
6594
7055
  { compressEnabled: false, compressionSummary: getCompressionSummary(sk) }
6595
7056
  );
6596
- const { model: spawnModelId, fast: fastMode } = parseModelId(effectiveModelId);
7057
+ const { model: spawnModelId, fast: fastMode } = parseModelId(failover.modelId);
6597
7058
  const skillPluginDirs = await resolveSkillPluginDirs({
6598
7059
  cwd,
6599
- cliPath: this.config.cliPath,
7060
+ cliPath,
6600
7061
  enabled: this.config.bridgeOpencodeSkills === true
6601
7062
  });
6602
7063
  const cliArgs = buildCliArgs({
@@ -6623,7 +7084,7 @@ var ClaudeCodeLanguageModel = class {
6623
7084
  });
6624
7085
  const { spawn: spawn2 } = await import("child_process");
6625
7086
  const { createInterface: createInterface2 } = await import("readline");
6626
- const proc = spawn2(this.config.cliPath, cliArgs, {
7087
+ const proc = spawn2(cliPath, cliArgs, {
6627
7088
  cwd,
6628
7089
  stdio: ["pipe", "pipe", "pipe"],
6629
7090
  env: claudeSpawnEnv({
@@ -6918,7 +7379,6 @@ ${plan}
6918
7379
  }
6919
7380
  async doStream(options) {
6920
7381
  const warnings = [];
6921
- const cliPath = this.config.cliPath;
6922
7382
  const skipPermissions = this.config.skipPermissions !== false;
6923
7383
  const scope = this.requestScope(options);
6924
7384
  const affinity = this.sessionAffinity(options);
@@ -6928,7 +7388,6 @@ ${plan}
6928
7388
  this.getOpencodeAgent(options.providerOptions),
6929
7389
  this.modelId
6930
7390
  );
6931
- const { model: spawnModelId, fast: fastMode } = parseModelId(effectiveModelId);
6932
7391
  const reasoningEffort = compactionMode ? void 0 : resolveAgentEffort(
6933
7392
  this.getOpencodeAgent(options.providerOptions),
6934
7393
  this.getReasoningEffort(options.providerOptions)
@@ -6945,6 +7404,17 @@ ${plan}
6945
7404
  const interactivePref = this.config.interactive ?? flagOn(process.env.CLAUDE_CODE_INTERACTIVE_TRANSPORT);
6946
7405
  const useInteractive = interactivePref && typeof globalThis.Bun?.Terminal === "function";
6947
7406
  const interactiveBypassRequested = this.config.interactiveBypass ?? flagOn(process.env.CLAUDE_CODE_INTERACTIVE_BYPASS);
7407
+ const sourceAccount = normalizeAccountName(
7408
+ this.config.account ?? DEFAULT_ACCOUNT
7409
+ );
7410
+ const baseCliPath = this.config.baseCliPath ?? this.config.cliPath;
7411
+ let failover = useInteractive || compactionMode ? { cliPath: this.config.cliPath, modelId: effectiveModelId, failedOver: false } : await resolveFailoverSpawn({
7412
+ account: sourceAccount,
7413
+ baseCliPath,
7414
+ cliPath: this.config.cliPath,
7415
+ modelId: effectiveModelId
7416
+ });
7417
+ let cliPath = failover.cliPath;
6948
7418
  const asideTransportRef = { cliPath, interactive: !!useInteractive };
6949
7419
  const doctor = !compactionMode && scope !== "no-tools" ? parseDoctorCommand(options.prompt) : null;
6950
7420
  if (doctor) {
@@ -7087,15 +7557,90 @@ ${plan}
7087
7557
  deleteActiveProcess(sk);
7088
7558
  clearCompression(sk);
7089
7559
  }
7560
+ const failoverAnswer = compactionMode || useInteractive ? null : consumeAccountFailoverAnswer(sk, options.prompt);
7561
+ if (failoverAnswer?.kind === "stop") {
7562
+ log.warn("account failover declined; ending the turn", {
7563
+ sessionKey: sk,
7564
+ account: sourceAccount,
7565
+ reason: failoverAnswer.reason
7566
+ });
7567
+ const note = formatFailoverStopNote(failoverAnswer.reason);
7568
+ const stream2 = new ReadableStream({
7569
+ start(controller) {
7570
+ controller.enqueue({ type: "stream-start", warnings });
7571
+ const id = generateId();
7572
+ controller.enqueue({ type: "text-start", id });
7573
+ controller.enqueue({ type: "text-delta", id, delta: note });
7574
+ controller.enqueue({ type: "text-end", id });
7575
+ controller.enqueue({
7576
+ type: "error",
7577
+ error: new Error(
7578
+ `Claude account "${sourceAccount}" is out of usage and no other account was picked.`
7579
+ )
7580
+ });
7581
+ controller.enqueue({
7582
+ type: "finish",
7583
+ finishReason: { unified: "error", raw: "account_limit" },
7584
+ usage: toUsage({ input_tokens: 0, output_tokens: 0 }),
7585
+ providerMetadata: {
7586
+ "claude-code": {
7587
+ path: "account-failover-stop",
7588
+ synthetic: true,
7589
+ usageUnavailable: true
7590
+ }
7591
+ }
7592
+ });
7593
+ controller.close();
7594
+ }
7595
+ });
7596
+ return { stream: stream2, request: { body: { text: "" } } };
7597
+ }
7598
+ let failoverNote = null;
7599
+ if (failoverAnswer?.kind === "switch") {
7600
+ setAccountOverride(
7601
+ failoverAnswer.sourceAccount,
7602
+ failoverAnswer.target,
7603
+ failoverUntil(failoverAnswer.resetsAt)
7604
+ );
7605
+ failover = await resolveFailoverSpawn({
7606
+ account: sourceAccount,
7607
+ baseCliPath,
7608
+ cliPath: this.config.cliPath,
7609
+ modelId: effectiveModelId
7610
+ });
7611
+ cliPath = failover.cliPath;
7612
+ asideTransportRef.cliPath = cliPath;
7613
+ failoverNote = formatFailoverNote({
7614
+ sourceAccount: failoverAnswer.sourceAccount,
7615
+ target: failoverAnswer.target,
7616
+ resetsAt: failoverAnswer.resetsAt
7617
+ });
7618
+ }
7619
+ const processForAccount = getActiveProcess(sk);
7620
+ if (!compactionMode && !useInteractive && processForAccount?.cliPath && processForAccount.cliPath !== cliPath) {
7621
+ log.notice("claude process belongs to another account; starting fresh", {
7622
+ sessionKey: sk,
7623
+ was: processForAccount.cliPath,
7624
+ now: cliPath,
7625
+ failedOver: failover.failedOver
7626
+ });
7627
+ deleteActiveProcess(sk);
7628
+ deleteClaudeSessionId(sk);
7629
+ }
7090
7630
  const hasExistingSession = !!getClaudeSessionId(sk);
7091
7631
  const hasActiveProcess = !!getActiveProcess(sk);
7092
- const includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation;
7632
+ let includeHistoryContext = !hasExistingSession && !hasActiveProcess && hasPriorConversation;
7633
+ if (failoverAnswer?.kind === "switch" && hasPriorConversation) {
7634
+ includeHistoryContext = true;
7635
+ }
7636
+ const { model: spawnModelId, fast: fastMode } = parseModelId(failover.modelId);
7093
7637
  const exitPlanModeQuestionResult = compactionMode ? null : consumeExitPlanModeQuestionResult(sk, options.prompt);
7094
7638
  if (exitPlanModeQuestionResult) {
7095
7639
  log.info("sending plan approval decision to claude", { sk });
7096
7640
  }
7097
7641
  const previousPendingProxyCalls = compactionMode ? [] : getPendingProxyCalls(sk);
7098
- const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(options.prompt, includeHistoryContext, {
7642
+ const effectivePrompt = failoverAnswer?.kind === "switch" ? buildFailoverContinuationPrompt(options.prompt, failoverAnswer.target) : options.prompt;
7643
+ const userMsg = exitPlanModeQuestionResult ?? getClaudeUserMessage(effectivePrompt, includeHistoryContext, {
7099
7644
  compactionMode,
7100
7645
  cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)),
7101
7646
  stripContextReminders: this.stripContextRemindersEnabled()
@@ -7116,8 +7661,23 @@ ${plan}
7116
7661
  );
7117
7662
  const [runtimeStatus, cliVersion] = await Promise.all([
7118
7663
  compactionMode ? Promise.resolve(void 0) : getRuntimeMcpStatus(),
7119
- detectCliVersion(this.config.cliPath)
7664
+ detectCliVersion(cliPath)
7120
7665
  ]);
7666
+ const failoverAccounts = failoverCandidates(
7667
+ this.config.failoverAccounts,
7668
+ sourceAccount
7669
+ );
7670
+ const failoverAskActive = failoverAccounts.length > 0 && this.config.accountFailover !== "off" && !compactionMode && !useInteractive && isAccountFailoverQuestionActive({
7671
+ configured: this.config.accountFailover,
7672
+ candidates: failoverAccounts,
7673
+ opencodeHasQuestion: (await loadLiveToolInfo()).hasQuestion,
7674
+ compactionMode,
7675
+ interactive: !!useInteractive,
7676
+ // A subagent follows its parent's account for free, because the
7677
+ // override is account-scoped. Asking it would put a form in a session
7678
+ // the operator is usually not even looking at.
7679
+ childSession: !!await fetchSessionParentId(affinity)
7680
+ });
7121
7681
  log.info("doStream starting", {
7122
7682
  cwd,
7123
7683
  model: effectiveModelId,
@@ -7435,6 +7995,14 @@ ${plan}
7435
7995
  currentTextId = null;
7436
7996
  }
7437
7997
  };
7998
+ if (failoverNote) {
7999
+ controller.enqueue({
8000
+ type: "text-delta",
8001
+ id: startTextBlock(),
8002
+ delta: failoverNote
8003
+ });
8004
+ endTextBlock();
8005
+ }
7438
8006
  const reasoningIds = /* @__PURE__ */ new Map();
7439
8007
  const reasoningStarted = /* @__PURE__ */ new Map();
7440
8008
  let hadThinkingTextFromStream = false;
@@ -7469,7 +8037,12 @@ ${plan}
7469
8037
  resultFallbackTimer = null;
7470
8038
  }
7471
8039
  };
7472
- const startResultFallback = (delayMs = 6e4) => {
8040
+ const RESULT_FALLBACK_MS = (() => {
8041
+ const env = process.env.CLAUDE_CODE_RESULT_FALLBACK_MS;
8042
+ const parsed = env ? Number.parseInt(env, 10) : NaN;
8043
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 6e4;
8044
+ })();
8045
+ const startResultFallback = (delayMs = RESULT_FALLBACK_MS) => {
7473
8046
  clearFallbackTimer();
7474
8047
  if (!hasReceivedContent && !hasReceivedProgress || controllerClosed) return;
7475
8048
  resultFallbackTimer = setTimeout(() => {
@@ -7477,6 +8050,14 @@ ${plan}
7477
8050
  log.warn("result fallback timer fired \u2014 closing stream without result event", {
7478
8051
  delayMs
7479
8052
  });
8053
+ if (!autoContinueState.aborted) {
8054
+ controller.enqueue({
8055
+ type: "text-delta",
8056
+ id: startTextBlock(),
8057
+ delta: formatStreamTimeoutNote(delayMs)
8058
+ });
8059
+ endTextBlock();
8060
+ }
7480
8061
  closeHandler();
7481
8062
  }, delayMs);
7482
8063
  };
@@ -7607,6 +8188,7 @@ ${plan}
7607
8188
  const toolCallsById = /* @__PURE__ */ new Map();
7608
8189
  let resultMeta = {};
7609
8190
  let resultFailure;
8191
+ let accountLimitHit = null;
7610
8192
  const drainBuffer = [];
7611
8193
  let drainTimer = null;
7612
8194
  const DRAIN_QUIET_MS = 100;
@@ -7658,7 +8240,7 @@ ${plan}
7658
8240
  } catch {
7659
8241
  }
7660
8242
  };
7661
- const finishWithExitPlanQuestion = (call) => {
8243
+ const finishWithQuestionCall = (call) => {
7662
8244
  if (controllerClosed) return;
7663
8245
  endTextBlock();
7664
8246
  controller.enqueue({
@@ -7768,6 +8350,25 @@ ${plan}
7768
8350
  });
7769
8351
  }
7770
8352
  activeProcess?.pendingProxyCompletions?.clear();
8353
+ if (accountLimitHit && failoverAskActive) {
8354
+ const call = createAccountFailoverQuestionCall(sk, {
8355
+ sourceAccount,
8356
+ candidates: failoverAccounts,
8357
+ resetsAt: accountLimitHit.resetsAt,
8358
+ window: accountLimitHit.window
8359
+ });
8360
+ log.warn(
8361
+ `Claude account "${sourceAccount}" is out of usage; asking which account to continue on.`,
8362
+ {
8363
+ sessionKey: sk,
8364
+ candidates: failoverAccounts,
8365
+ toolCallId: call.toolCallId,
8366
+ resetsAt: accountLimitHit.resetsAt ?? null
8367
+ }
8368
+ );
8369
+ finishWithQuestionCall(call);
8370
+ return;
8371
+ }
7771
8372
  const autoDecision = shouldAutoContinueIncompleteTurn(
7772
8373
  autoContinueState,
7773
8374
  {
@@ -7901,6 +8502,13 @@ ${plan}
7901
8502
  }
7902
8503
  }
7903
8504
  if (msg.type === "rate_limit_event") {
8505
+ const info = parseRateLimitEvent(msg);
8506
+ if (info && isRateLimitRejected(info)) {
8507
+ accountLimitHit = {
8508
+ resetsAt: info.resetsAt ?? info.overageResetsAt,
8509
+ window: info.rateLimitType
8510
+ };
8511
+ }
7904
8512
  const note = reportRateLimitEvent(msg);
7905
8513
  if (note) {
7906
8514
  controller.enqueue({ type: "text-delta", id: startTextBlock(), delta: note });
@@ -8064,7 +8672,7 @@ ${plan}
8064
8672
  id: planId2,
8065
8673
  delta: questionCall.text
8066
8674
  });
8067
- finishWithExitPlanQuestion(questionCall);
8675
+ finishWithQuestionCall(questionCall);
8068
8676
  return;
8069
8677
  }
8070
8678
  const planId = startTextBlock();
@@ -8245,7 +8853,7 @@ ${plan}
8245
8853
  id: planId2,
8246
8854
  delta: questionCall.text
8247
8855
  });
8248
- finishWithExitPlanQuestion(questionCall);
8856
+ finishWithQuestionCall(questionCall);
8249
8857
  return;
8250
8858
  }
8251
8859
  const planId = startTextBlock();
@@ -8417,6 +9025,11 @@ ${plan}
8417
9025
  delta: msg.result
8418
9026
  });
8419
9027
  }
9028
+ if (!accountLimitHit && msg.is_error && isAccountLimitError({
9029
+ resultText: typeof msg.result === "string" ? msg.result : null
9030
+ })) {
9031
+ accountLimitHit = {};
9032
+ }
8420
9033
  const failure = describeResultFailure(msg);
8421
9034
  if (failure) {
8422
9035
  resultFailure = msg.subtype;
@@ -8840,148 +9453,6 @@ ${plan}
8840
9453
  }
8841
9454
  };
8842
9455
 
8843
- // src/accounts.ts
8844
- import { chmod, lstat, mkdir, readlink, symlink, writeFile } from "fs/promises";
8845
- import path8 from "path";
8846
- var BASE_PROVIDER_ID = "claude-code";
8847
- var DEFAULT_ACCOUNT = "default";
8848
- var SHARED_CAPABILITY_ITEMS = [
8849
- "CLAUDE.md",
8850
- "settings.json",
8851
- "skills",
8852
- "agents",
8853
- "commands",
8854
- "plugins"
8855
- ];
8856
- function normalizeAccountName(account) {
8857
- return account.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8858
- }
8859
- function resolveAccounts(value) {
8860
- if (!Array.isArray(value)) return null;
8861
- const accounts = value.map((account) => normalizeAccountName(String(account))).filter(Boolean);
8862
- return Array.from(/* @__PURE__ */ new Set([DEFAULT_ACCOUNT, ...accounts]));
8863
- }
8864
- function accountProviderId(account) {
8865
- return `${BASE_PROVIDER_ID}-${normalizeAccountName(account)}`;
8866
- }
8867
- function accountDisplayName(account) {
8868
- return `Claude Code (${titleizeAccount(account)})`;
8869
- }
8870
- function accountModelSuffix(account) {
8871
- const normalized = normalizeAccountName(account);
8872
- return normalized === DEFAULT_ACCOUNT ? void 0 : normalized;
8873
- }
8874
- function accountConfigDir(account) {
8875
- const normalized = normalizeAccountName(account);
8876
- if (!normalized || normalized === DEFAULT_ACCOUNT) return void 0;
8877
- return `~/.claude-${normalized}`;
8878
- }
8879
- function expandHome(value) {
8880
- const home = process.env.HOME ?? process.env.USERPROFILE;
8881
- if (value === "~") return home ?? value;
8882
- if (value.startsWith("~/") || value.startsWith("~\\")) {
8883
- return home ? path8.join(home, value.slice(2)) : value;
8884
- }
8885
- return value;
8886
- }
8887
- async function ensureAccountRuntime(account, baseCliPath) {
8888
- const configDir = accountConfigDir(account);
8889
- if (!configDir) return { cliPath: baseCliPath };
8890
- const expandedConfigDir = expandHome(configDir);
8891
- await mkdir(expandedConfigDir, { recursive: true });
8892
- try {
8893
- await ensureSharedCapabilities(expandedConfigDir);
8894
- } catch (err) {
8895
- log.warn("failed to symlink shared capabilities; continuing anyway", {
8896
- account,
8897
- configDir: expandedConfigDir,
8898
- error: String(err)
8899
- });
8900
- }
8901
- const cliPath = await writeAccountWrapper(
8902
- normalizeAccountName(account),
8903
- baseCliPath,
8904
- expandedConfigDir
8905
- );
8906
- return { cliPath, configDir: expandedConfigDir };
8907
- }
8908
- async function ensureSharedCapabilities(targetRoot) {
8909
- const sourceRoot = expandHome("~/.claude");
8910
- for (const item of SHARED_CAPABILITY_ITEMS) {
8911
- await ensureSharedCapabilityItem(sourceRoot, targetRoot, item);
8912
- }
8913
- }
8914
- async function ensureSharedCapabilityItem(sourceRoot, targetRoot, item) {
8915
- const source = path8.join(sourceRoot, item);
8916
- const target = path8.join(targetRoot, item);
8917
- let sourceStat;
8918
- try {
8919
- sourceStat = await lstat(source);
8920
- } catch {
8921
- return;
8922
- }
8923
- try {
8924
- const targetStat = await lstat(target);
8925
- if (targetStat.isSymbolicLink()) {
8926
- const current = await readlink(target);
8927
- const resolvedCurrent = path8.resolve(path8.dirname(target), current);
8928
- const resolvedSource = path8.resolve(source);
8929
- if (resolvedCurrent === resolvedSource) return;
8930
- }
8931
- log.warn("shared Claude capability already exists; leaving untouched", {
8932
- item,
8933
- target,
8934
- source
8935
- });
8936
- return;
8937
- } catch {
8938
- }
8939
- const type = sourceStat.isDirectory() ? process.platform === "win32" ? "junction" : "dir" : "file";
8940
- await symlink(source, target, type);
8941
- }
8942
- async function writeAccountWrapper(account, baseCliPath, configDir) {
8943
- const cacheRoot = path8.join(
8944
- process.env.XDG_CACHE_HOME ?? expandHome("~/.cache"),
8945
- "opencode-claude-code-plugin"
8946
- );
8947
- const wrapperPath = path8.join(cacheRoot, `claude-${account}`);
8948
- const suffix = `@${account}`;
8949
- await mkdir(cacheRoot, { recursive: true });
8950
- const script = `#!/usr/bin/env bash
8951
- set -euo pipefail
8952
-
8953
- args=()
8954
- while [[ $# -gt 0 ]]; do
8955
- if [[ "$1" == "--model" && $# -ge 2 ]]; then
8956
- model="$2"
8957
- if [[ "$model" == *${shellDoubleQuote(suffix)} ]]; then
8958
- model="\${model%${shellDoubleQuote(suffix)}}"
8959
- fi
8960
- args+=("$1" "$model")
8961
- shift 2
8962
- else
8963
- args+=("$1")
8964
- shift
8965
- fi
8966
- done
8967
-
8968
- export CLAUDE_CONFIG_DIR=${shellSingleQuote(configDir)}
8969
- exec ${shellSingleQuote(baseCliPath)} "\${args[@]}"
8970
- `;
8971
- await writeFile(wrapperPath, script, "utf8");
8972
- await chmod(wrapperPath, 493);
8973
- return wrapperPath;
8974
- }
8975
- function shellSingleQuote(value) {
8976
- return `'${value.replace(/'/g, `'"'"'`)}'`;
8977
- }
8978
- function shellDoubleQuote(value) {
8979
- return value.replace(/[$`"\\]/g, "\\$&");
8980
- }
8981
- function titleizeAccount(account) {
8982
- return normalizeAccountName(account).split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
8983
- }
8984
-
8985
9456
  // src/cleanup-stale.ts
8986
9457
  import {
8987
9458
  existsSync as existsSync4,
@@ -9176,6 +9647,9 @@ function createClaudeCode(settings = {}) {
9176
9647
  cwd: settings.cwd,
9177
9648
  account: settings.account,
9178
9649
  configDir: settings.configDir,
9650
+ failoverAccounts: settings.failoverAccounts,
9651
+ baseCliPath: settings.baseCliPath ?? cliPath,
9652
+ accountFailover: settings.accountFailover ?? "ask",
9179
9653
  providerID: settings.providerID,
9180
9654
  skipPermissions: settings.skipPermissions ?? true,
9181
9655
  permissionMode: settings.permissionMode,
@@ -9302,7 +9776,11 @@ async function providerConfig(existing, providerID = PROVIDER_ID2, optionDefault
9302
9776
  npm: existing?.npm ?? pluginEntrypoint(),
9303
9777
  options: {
9304
9778
  ...mergedOptions,
9305
- ...runtime
9779
+ ...runtime,
9780
+ // The pre-wrapper binary, kept because `runtime` replaces `cliPath`
9781
+ // with the account's wrapper and a failover has to build ANOTHER
9782
+ // account's wrapper on top of the same base (src/account-failover.ts).
9783
+ baseCliPath: cliPath
9306
9784
  }
9307
9785
  // models is intentionally omitted: both callers overwrite it with
9308
9786
  // configModelsForProvider(), which emits the flat config schema
@@ -9335,7 +9813,11 @@ async function expandAccountProviders(config) {
9335
9813
  providerID,
9336
9814
  {
9337
9815
  ...seedOptions,
9338
- account
9816
+ account,
9817
+ // The resolved list, so this account's language model can offer
9818
+ // the others when it runs out of usage. `accounts` itself stays
9819
+ // stripped by cleanProviderOptions.
9820
+ failoverAccounts: accounts
9339
9821
  },
9340
9822
  accountDisplayName(account)
9341
9823
  ),