@khalilgharbaoui/opencode-claude-code-plugin 0.54.2 → 0.56.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
@@ -1,5 +1,5 @@
1
1
  // src/claude-code-language-model.ts
2
- import { join as join16 } from "path";
2
+ import { join as join17 } from "path";
3
3
 
4
4
  // src/ids.ts
5
5
  import { randomInt } from "crypto";
@@ -151,7 +151,7 @@ function translateStreamForHost(stream, dialect = "v1") {
151
151
  }
152
152
 
153
153
  // src/account-failover.ts
154
- import { homedir as homedir2 } from "os";
154
+ import { homedir as homedir3 } from "os";
155
155
 
156
156
  // src/accounts.ts
157
157
  import {
@@ -705,6 +705,198 @@ ${ACCOUNT_GROUP_MARKER} this conversation was running on the Claude account "${s
705
705
  `;
706
706
  }
707
707
 
708
+ // src/account-usage.ts
709
+ import { mkdirSync as mkdirSync2, readFileSync, renameSync as renameSync2, statSync as statSync2, writeFileSync } from "fs";
710
+ import { homedir as homedir2 } from "os";
711
+ import { join as join2 } from "path";
712
+ var DEFAULT_USAGE_WARN_THRESHOLDS = [50, 75, 90, 95];
713
+ var DEFAULT_USAGE_SWITCH_AT = 90;
714
+ var UNTIMED_READING_TTL_MS = 5 * 60 * 60 * 1e3;
715
+ var USAGE_READING_MAX_AGE_MS = 8 * 24 * 60 * 60 * 1e3;
716
+ var MAX_USAGE_ACCOUNTS = 32;
717
+ var MAX_USAGE_WINDOWS = 8;
718
+ var NAME = /^[a-z0-9_-]{1,64}$/;
719
+ var STATUS = /^[a-z_]{1,32}$/;
720
+ var MAX_RESETS_TEXT = 80;
721
+ var pathOverride = null;
722
+ var cache = null;
723
+ function accountUsageStorePath() {
724
+ if (pathOverride) return pathOverride;
725
+ const stateRoot = process.env.XDG_STATE_HOME || join2(homedir2(), ".local", "state");
726
+ return join2(stateRoot, "opencode-claude-code-plugin", "account-usage.json");
727
+ }
728
+ function finite(value) {
729
+ return typeof value === "number" && Number.isFinite(value);
730
+ }
731
+ function validEntry(value) {
732
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
733
+ const entry = value;
734
+ if (!finite(entry.observedAt)) return null;
735
+ if (typeof entry.status !== "string" || !STATUS.test(entry.status)) return null;
736
+ if (entry.utilization !== null && !finite(entry.utilization)) return null;
737
+ if (entry.resetsAt !== void 0 && !finite(entry.resetsAt)) return null;
738
+ if (entry.resetsText !== void 0 && (typeof entry.resetsText !== "string" || entry.resetsText.length > MAX_RESETS_TEXT)) {
739
+ return null;
740
+ }
741
+ const noted = Array.isArray(entry.noted) ? [...new Set(entry.noted.filter((t) => finite(t) && t > 0 && t <= 100))].sort(
742
+ (a, b) => a - b
743
+ ) : [];
744
+ return {
745
+ utilization: entry.utilization,
746
+ ...entry.resetsAt === void 0 ? {} : { resetsAt: entry.resetsAt },
747
+ ...entry.resetsText === void 0 ? {} : { resetsText: entry.resetsText },
748
+ status: entry.status,
749
+ observedAt: entry.observedAt,
750
+ noted
751
+ };
752
+ }
753
+ function readStore(file, now) {
754
+ const out = /* @__PURE__ */ new Map();
755
+ try {
756
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
757
+ const accounts = parsed && typeof parsed === "object" ? parsed.accounts : null;
758
+ if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) return out;
759
+ const cutoff = now - USAGE_READING_MAX_AGE_MS;
760
+ for (const [account, windows] of Object.entries(accounts)) {
761
+ if (!NAME.test(account) || !windows || typeof windows !== "object") continue;
762
+ const kept = /* @__PURE__ */ new Map();
763
+ for (const [window, raw] of Object.entries(windows)) {
764
+ if (!NAME.test(window)) continue;
765
+ const entry = validEntry(raw);
766
+ if (entry && entry.observedAt >= cutoff) kept.set(window, entry);
767
+ }
768
+ if (kept.size > 0) out.set(account, kept);
769
+ }
770
+ } catch {
771
+ }
772
+ return out;
773
+ }
774
+ function newestObserved(windows) {
775
+ let newest = 0;
776
+ for (const entry of windows.values()) newest = Math.max(newest, entry.observedAt);
777
+ return newest;
778
+ }
779
+ function bounded(usage, now) {
780
+ const cutoff = now - USAGE_READING_MAX_AGE_MS;
781
+ const accounts = [...usage.entries()].map(([account, windows]) => {
782
+ const kept = [...windows.entries()].filter(([, entry]) => entry.observedAt >= cutoff).sort((a, b) => a[1].observedAt - b[1].observedAt).slice(-MAX_USAGE_WINDOWS);
783
+ return [account, new Map(kept)];
784
+ }).filter(([, windows]) => windows.size > 0).sort((a, b) => newestObserved(a[1]) - newestObserved(b[1])).slice(-MAX_USAGE_ACCOUNTS);
785
+ return new Map(accounts);
786
+ }
787
+ function writeStore(file, usage) {
788
+ const accounts = {};
789
+ for (const [account, windows] of usage) accounts[account] = Object.fromEntries(windows);
790
+ try {
791
+ mkdirSync2(join2(file, ".."), { recursive: true, mode: 448 });
792
+ const tmp = `${file}.${process.pid}.tmp`;
793
+ writeFileSync(tmp, JSON.stringify({ version: 1, accounts }), { mode: 384 });
794
+ renameSync2(tmp, file);
795
+ return true;
796
+ } catch {
797
+ return false;
798
+ }
799
+ }
800
+ function cached(now) {
801
+ const file = accountUsageStorePath();
802
+ if (!cache || cache.path !== file) cache = { path: file, usage: readStore(file, now) };
803
+ return cache.usage;
804
+ }
805
+ function refreshed(now) {
806
+ const file = accountUsageStorePath();
807
+ cache = { path: file, usage: readStore(file, now) };
808
+ return cache.usage;
809
+ }
810
+ function isReadingCurrent(entry, now = Date.now()) {
811
+ if (entry.resetsAt !== void 0) return entry.resetsAt > now;
812
+ return now - entry.observedAt < UNTIMED_READING_TTL_MS;
813
+ }
814
+ function applyReading(previous, reading, now = Date.now()) {
815
+ const carried = previous && isReadingCurrent(previous, now) ? previous.noted : [];
816
+ return {
817
+ utilization: finite(reading.utilization) ? reading.utilization : null,
818
+ ...finite(reading.resetsAt) ? { resetsAt: reading.resetsAt } : {},
819
+ ...reading.resetsText && !finite(reading.resetsAt) ? { resetsText: reading.resetsText.slice(0, MAX_RESETS_TEXT) } : {},
820
+ status: STATUS.test(reading.status) ? reading.status : "unknown",
821
+ observedAt: now,
822
+ noted: [...carried]
823
+ };
824
+ }
825
+ function usagePercent(utilization) {
826
+ return Math.round(utilization * 1e3) / 10;
827
+ }
828
+ function pendingCrossings(entry, thresholds, now = Date.now()) {
829
+ if (!isReadingCurrent(entry, now)) return [];
830
+ if (entry.utilization === null || entry.status === "rejected") return [];
831
+ const percent = usagePercent(entry.utilization);
832
+ return [...new Set(thresholds)].filter((threshold) => percent >= threshold && !entry.noted.includes(threshold)).sort((a, b) => a - b);
833
+ }
834
+ function resolveUsageWarnThresholds(value) {
835
+ if (!Array.isArray(value)) return [...DEFAULT_USAGE_WARN_THRESHOLDS];
836
+ return [...new Set(value.filter((t) => finite(t) && t > 0 && t <= 100))].sort(
837
+ (a, b) => a - b
838
+ );
839
+ }
840
+ function resolveUsageSwitchAt(value) {
841
+ if (value === false || value === 0) return null;
842
+ if (finite(value) && value > 0 && value <= 100) return value;
843
+ return DEFAULT_USAGE_SWITCH_AT;
844
+ }
845
+ function recordUsageReading(account, window, reading, now = Date.now()) {
846
+ if (!NAME.test(account) || !NAME.test(window)) return null;
847
+ const file = accountUsageStorePath();
848
+ const merged = readStore(file, now);
849
+ const previous = merged.get(account)?.get(window) ?? cached(now).get(account)?.get(window);
850
+ const entry = applyReading(previous, reading, now);
851
+ const windows = merged.get(account) ?? /* @__PURE__ */ new Map();
852
+ windows.set(window, entry);
853
+ merged.set(account, windows);
854
+ const kept = bounded(merged, now);
855
+ cache = { path: file, usage: kept };
856
+ return writeStore(file, kept) ? entry : null;
857
+ }
858
+ function markUsageNoted(account, window, thresholds, now = Date.now()) {
859
+ if (!NAME.test(account) || !NAME.test(window) || thresholds.length === 0) return false;
860
+ const file = accountUsageStorePath();
861
+ const merged = readStore(file, now);
862
+ const entry = merged.get(account)?.get(window) ?? cached(now).get(account)?.get(window);
863
+ if (!entry) return false;
864
+ const updated = {
865
+ ...entry,
866
+ noted: [.../* @__PURE__ */ new Set([...entry.noted, ...thresholds])].sort((a, b) => a - b)
867
+ };
868
+ const windows = merged.get(account) ?? /* @__PURE__ */ new Map();
869
+ windows.set(window, updated);
870
+ merged.set(account, windows);
871
+ const kept = bounded(merged, now);
872
+ cache = { path: file, usage: kept };
873
+ return writeStore(file, kept);
874
+ }
875
+ function usageCrossings(account, thresholds, now = Date.now()) {
876
+ if (thresholds.length === 0) return [];
877
+ const collect = (usage) => {
878
+ const out = [];
879
+ for (const [window, entry] of usage.get(account) ?? []) {
880
+ const crossed = pendingCrossings(entry, thresholds, now);
881
+ if (crossed.length > 0) out.push({ window, entry, crossed });
882
+ }
883
+ return out;
884
+ };
885
+ if (collect(cached(now)).length === 0) return [];
886
+ return collect(refreshed(now));
887
+ }
888
+ function accountUsageAtOrOver(account, percent, now = Date.now()) {
889
+ for (const entry of cached(now).get(account)?.values() ?? []) {
890
+ if (!isReadingCurrent(entry, now)) continue;
891
+ if (entry.status === "rejected") return true;
892
+ if (entry.utilization !== null && usagePercent(entry.utilization) >= percent) return true;
893
+ }
894
+ return false;
895
+ }
896
+ function snapshotAccountUsage(now = Date.now()) {
897
+ return refreshed(now);
898
+ }
899
+
708
900
  // src/types.ts
709
901
  var READ_ONLY_PERMISSION_MODE = "read-only";
710
902
  var DEFAULT_PROXY_TOOL_NAMES = [
@@ -1079,9 +1271,9 @@ function reportConversationReset(msg) {
1079
1271
  }
1080
1272
  var RESULT_ERROR_MARKER = "\u258C **claude code error:**";
1081
1273
  var RESULT_SUBTYPES = {
1082
- error_max_turns: "it hit its internal turn limit before finishing",
1274
+ error_max_turns: "it reached its `--max-turns` cap (the agent's `maxTurns`) before finishing this message",
1083
1275
  error_during_execution: "it failed while running the turn",
1084
- error_max_budget_usd: "it hit the configured spend limit for the turn",
1276
+ error_max_budget_usd: "it reached its `--max-budget-usd` cap (the agent's `maxBudgetUsd`), which counts this process's whole life",
1085
1277
  error_max_structured_output_retries: "it could not produce valid structured output"
1086
1278
  };
1087
1279
  function describeResultFailure(msg) {
@@ -1529,7 +1721,7 @@ function accountBlockKind(msg) {
1529
1721
  function describeAccountBlock(kind) {
1530
1722
  return ACCOUNT_BLOCKS[kind].what;
1531
1723
  }
1532
- function loginCommandFor(configDir, home = homedir2()) {
1724
+ function loginCommandFor(configDir, home = homedir3()) {
1533
1725
  if (!configDir) return "claude auth login";
1534
1726
  const shown = configDir === home || configDir.startsWith(`${home}/`) ? `~${configDir.slice(home.length)}` : configDir;
1535
1727
  return `CLAUDE_CONFIG_DIR=${shown} claude auth login`;
@@ -1560,6 +1752,28 @@ function formatUsageLimitNote(input) {
1560
1752
  ${USAGE_LIMIT_MARKER} ${what} ${advice}
1561
1753
  `;
1562
1754
  }
1755
+ var USAGE_MARKER = "\u258C **usage:**";
1756
+ function formatUsageNote(input) {
1757
+ const account = normalizeAccountName(input.account || DEFAULT_ACCOUNT);
1758
+ const clauses = input.windows.map((entry) => {
1759
+ const window = describeRateLimitWindow(entry.window) ?? "a usage window";
1760
+ const resets = formatResetsAtLocal(entry.resetsAt);
1761
+ const when = resets ? `, which resets at ${resets}` : entry.resetsText ? `, which resets ${entry.resetsText}` : "";
1762
+ return `${entry.percent}% of ${window}${when}`;
1763
+ });
1764
+ const used = clauses.length <= 1 ? clauses.join("") : `${clauses.slice(0, -1).join("; ")}; and ${clauses[clauses.length - 1]}`;
1765
+ let then = "";
1766
+ if (input.movedTo) {
1767
+ const until = formatResetsAtLocal(input.movedTo.until);
1768
+ then = ` New turns move to the "${normalizeAccountName(input.movedTo.target)}" account ${until ? `until ${until}` : "until opencode restarts"}.`;
1769
+ } else {
1770
+ const others = describeOtherAccounts(input.candidates ?? []);
1771
+ if (others) then = ` To keep working past the limit, pick a model from ${others}.`;
1772
+ }
1773
+ return `
1774
+ ${USAGE_MARKER} the Claude account "${account}" has used ${used}.${then}
1775
+ `;
1776
+ }
1563
1777
  function isAccountLimitError(input) {
1564
1778
  if (input.rateLimit && isRateLimitRejected(input.rateLimit)) return true;
1565
1779
  const text = input.resultText;
@@ -1591,7 +1805,7 @@ function recallAccountLimit(account, now = Date.now()) {
1591
1805
  };
1592
1806
  }
1593
1807
  var accountOverrides = /* @__PURE__ */ new Map();
1594
- function setAccountOverride(source, target, until, now = Date.now()) {
1808
+ function setAccountOverride(source, target, until, now = Date.now(), cause = "usage-limit") {
1595
1809
  const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
1596
1810
  const to = normalizeAccountName(target);
1597
1811
  if (!to || to === from) return;
@@ -1604,6 +1818,14 @@ function setAccountOverride(source, target, until, now = Date.now()) {
1604
1818
  until = void 0;
1605
1819
  }
1606
1820
  accountOverrides.set(from, { target: to, until });
1821
+ if (cause === "usage-threshold") {
1822
+ log.warn("claude account is near its usage limit; new turns move to another account until the window resets", {
1823
+ source: from,
1824
+ target: to,
1825
+ until: until ?? null
1826
+ });
1827
+ return;
1828
+ }
1607
1829
  log.warn(
1608
1830
  `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"}.`,
1609
1831
  { source: from, target: to, until: until ?? null }
@@ -1623,6 +1845,9 @@ function resolveAccountOverride(source, now = Date.now()) {
1623
1845
  }
1624
1846
  return entry.target;
1625
1847
  }
1848
+ function clearAccountOverride(source) {
1849
+ accountOverrides.delete(normalizeAccountName(source || DEFAULT_ACCOUNT));
1850
+ }
1626
1851
  function stripAccountSuffix(modelId) {
1627
1852
  const at = modelId.indexOf("@");
1628
1853
  return at === -1 ? modelId : modelId.slice(0, at);
@@ -1671,6 +1896,20 @@ function failoverCandidates(accounts, source, groups) {
1671
1896
  }
1672
1897
  return out;
1673
1898
  }
1899
+ function isAccountKnownUnavailable(account, now = Date.now()) {
1900
+ if (resolveAccountOverride(account, now) !== void 0) return true;
1901
+ return recallAccountLimit(account, now).resetsAt !== void 0;
1902
+ }
1903
+ function nextAutoFailoverAccount(candidates, tried, now = Date.now()) {
1904
+ return candidates.find(
1905
+ (account) => !tried.has(account) && !isAccountKnownUnavailable(account, now)
1906
+ );
1907
+ }
1908
+ function nextUsageSwitchAccount(candidates, exclude, switchAt, now = Date.now()) {
1909
+ return candidates.find(
1910
+ (account) => !exclude.has(account) && !isAccountKnownUnavailable(account, now) && !accountUsageAtOrOver(account, switchAt, now)
1911
+ );
1912
+ }
1674
1913
  var pendingQuestions2 = /* @__PURE__ */ new Map();
1675
1914
  function pendingKey2(sessionKey2, toolCallId) {
1676
1915
  return `${sessionKey2}${KEY_SEPARATOR2}${toolCallId}`;
@@ -1797,6 +2036,13 @@ function formatFailoverNote(input) {
1797
2036
  ${FAILOVER_MARKER} "${input.sourceAccount}" is out of usage, so this conversation continues on "${input.target}" ${resets ? `until ${resets}` : "until opencode restarts"}. Claude cannot resume a session across accounts, so the thread is being replayed into a fresh one.
1798
2037
  `;
1799
2038
  }
2039
+ function formatAutoFailoverNote(input) {
2040
+ const resets = describeReset(input.resetsAt);
2041
+ const arrival = input.carried ? "The conversation continues where it stopped." : "The conversation is replayed into a fresh Claude session there.";
2042
+ return `
2043
+ ${FAILOVER_MARKER} "${input.failedAccount}" ${input.reason ?? "is out of usage"}, so this turn moved to "${input.target}" automatically and stays there until ${resets ?? "opencode restarts"}. ${arrival}
2044
+ `;
2045
+ }
1800
2046
  function formatFailoverStopNote(reason) {
1801
2047
  return `
1802
2048
  ${FAILOVER_MARKER} Staying on this account (${reason}). The turn ends here; the usage limit is unchanged.
@@ -1845,7 +2091,7 @@ function failoverUntil(resetsAt) {
1845
2091
  }
1846
2092
 
1847
2093
  // src/subagent-dispatch.ts
1848
- import { join as join4 } from "path";
2094
+ import { join as join5 } from "path";
1849
2095
 
1850
2096
  // src/agent-models.ts
1851
2097
  import { readFile, readdir } from "fs/promises";
@@ -2253,6 +2499,7 @@ var REASONING_EFFORTS = [
2253
2499
  var registry = {};
2254
2500
  var defaultSubagentModel;
2255
2501
  var defaultSubagentCacheTtl;
2502
+ var defaultSubagentCliCaps = {};
2256
2503
  var providerFallbackModels = [];
2257
2504
  function setAgentRegistry(records2) {
2258
2505
  registry = records2;
@@ -2269,6 +2516,15 @@ function getDefaultSubagentModel() {
2269
2516
  function setDefaultSubagentCacheTtl(ttl) {
2270
2517
  defaultSubagentCacheTtl = ttl?.trim() || void 0;
2271
2518
  }
2519
+ function setDefaultSubagentCliCaps(caps) {
2520
+ defaultSubagentCliCaps = {
2521
+ ...caps.maxTurns !== void 0 ? { maxTurns: caps.maxTurns } : {},
2522
+ ...caps.maxBudgetUsd !== void 0 ? { maxBudgetUsd: caps.maxBudgetUsd } : {}
2523
+ };
2524
+ }
2525
+ function getDefaultSubagentCliCaps() {
2526
+ return { ...defaultSubagentCliCaps };
2527
+ }
2272
2528
  function setProviderFallbackModels(models) {
2273
2529
  providerFallbackModels = models ?? [];
2274
2530
  }
@@ -2352,6 +2608,45 @@ function resolveAgentCacheTtl(agent, overrides) {
2352
2608
  log.debug("agent prompt cache ttl", { agent, ttl: wanted });
2353
2609
  return wanted;
2354
2610
  }
2611
+ var refusedMainAgentBudgets = /* @__PURE__ */ new Set();
2612
+ function resolveAgentCliCaps(agent, overrides) {
2613
+ if (!agent) return {};
2614
+ const record = (overrides?.records ?? registry)[agent];
2615
+ if (!record) return {};
2616
+ const defaults = overrides?.defaults ?? defaultSubagentCliCaps;
2617
+ const isSubagent = record.mode === "subagent";
2618
+ const caps = {};
2619
+ const turns = parseCap(record.maxTurns, true);
2620
+ if (record.maxTurns !== void 0 && turns === void 0) {
2621
+ log.warn("agent maxTurns refused: not a positive whole number", { agent });
2622
+ }
2623
+ const maxTurns = turns ?? (isSubagent ? defaults.maxTurns : void 0);
2624
+ if (maxTurns !== void 0) caps.maxTurns = maxTurns;
2625
+ const budget = parseCap(record.maxBudgetUsd, false);
2626
+ if (record.maxBudgetUsd !== void 0 && budget === void 0) {
2627
+ log.warn("agent maxBudgetUsd refused: not a positive number of dollars", { agent });
2628
+ }
2629
+ if (budget !== void 0 && !isSubagent) {
2630
+ if (!refusedMainAgentBudgets.has(agent)) {
2631
+ refusedMainAgentBudgets.add(agent);
2632
+ log.warn(
2633
+ "agent maxBudgetUsd ignored: the CLI counts it over a reused process's whole life, so it applies to subagents only",
2634
+ { agent }
2635
+ );
2636
+ }
2637
+ } else {
2638
+ const maxBudgetUsd = budget ?? (isSubagent ? defaults.maxBudgetUsd : void 0);
2639
+ if (maxBudgetUsd !== void 0) caps.maxBudgetUsd = maxBudgetUsd;
2640
+ }
2641
+ return caps;
2642
+ }
2643
+ function parseCap(value, integer) {
2644
+ if (value === void 0) return void 0;
2645
+ const parsed = typeof value === "string" ? value.trim() ? Number(value) : NaN : value;
2646
+ if (!Number.isFinite(parsed) || parsed <= 0) return void 0;
2647
+ if (integer && !Number.isInteger(parsed)) return void 0;
2648
+ return parsed;
2649
+ }
2355
2650
  function parseAgentFrontmatter(text) {
2356
2651
  const record = {};
2357
2652
  if (!text.startsWith("---")) return record;
@@ -2384,6 +2679,8 @@ function parseAgentFrontmatter(text) {
2384
2679
  else if (key === "forceModel") record.forceModel = value;
2385
2680
  else if (key === "reasoningEffort") record.reasoningEffort = value;
2386
2681
  else if (key === "cacheTtl") record.cacheTtl = value;
2682
+ else if (key === "maxTurns") record.maxTurns = value;
2683
+ else if (key === "maxBudgetUsd") record.maxBudgetUsd = value;
2387
2684
  }
2388
2685
  return record;
2389
2686
  }
@@ -2445,18 +2742,18 @@ function agentDirectories(home, projectDirectory) {
2445
2742
  }
2446
2743
 
2447
2744
  // src/dispatch-choice-store.ts
2448
- import { mkdirSync as mkdirSync2, readFileSync, renameSync as renameSync2, writeFileSync } from "fs";
2449
- import { homedir as homedir3 } from "os";
2450
- import { join as join2 } from "path";
2745
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync3, writeFileSync as writeFileSync2 } from "fs";
2746
+ import { homedir as homedir4 } from "os";
2747
+ import { join as join3 } from "path";
2451
2748
  var MAX_DISPATCH_CHOICE_SESSIONS = 128;
2452
2749
  var MAX_DISPATCH_CHOICES_PER_SESSION = 32;
2453
2750
  var DISPATCH_CHOICE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
2454
2751
  var sessions = null;
2455
- var pathOverride = null;
2752
+ var pathOverride2 = null;
2456
2753
  function dispatchChoiceStorePath() {
2457
- if (pathOverride) return pathOverride;
2458
- const stateRoot = process.env.XDG_STATE_HOME || join2(homedir3(), ".local", "state");
2459
- return join2(stateRoot, "opencode-claude-code-plugin", "subagent-dispatch.json");
2754
+ if (pathOverride2) return pathOverride2;
2755
+ const stateRoot = process.env.XDG_STATE_HOME || join3(homedir4(), ".local", "state");
2756
+ return join3(stateRoot, "opencode-claude-code-plugin", "subagent-dispatch.json");
2460
2757
  }
2461
2758
  function isChoice(value) {
2462
2759
  const choice = value;
@@ -2482,7 +2779,7 @@ function trimChoices(choices) {
2482
2779
  function readFile2(file) {
2483
2780
  const out = /* @__PURE__ */ new Map();
2484
2781
  try {
2485
- const parsed = JSON.parse(readFileSync(file, "utf8"));
2782
+ const parsed = JSON.parse(readFileSync2(file, "utf8"));
2486
2783
  const entries = parsed && typeof parsed === "object" ? parsed.sessions : null;
2487
2784
  if (!entries || typeof entries !== "object") return out;
2488
2785
  const cutoff = Date.now() - DISPATCH_CHOICE_MAX_AGE_MS;
@@ -2504,12 +2801,12 @@ function save(update) {
2504
2801
  const kept = [...merged.entries()].filter(([, record]) => record.updatedAt >= cutoff).filter(([, record]) => Object.keys(record.choices).length > 0).sort((a, b) => a[1].updatedAt - b[1].updatedAt).slice(-MAX_DISPATCH_CHOICE_SESSIONS);
2505
2802
  sessions = new Map(kept);
2506
2803
  try {
2507
- mkdirSync2(join2(file, ".."), { recursive: true, mode: 448 });
2804
+ mkdirSync3(join3(file, ".."), { recursive: true, mode: 448 });
2508
2805
  const tmp = `${file}.${process.pid}.tmp`;
2509
- writeFileSync(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
2806
+ writeFileSync2(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
2510
2807
  mode: 384
2511
2808
  });
2512
- renameSync2(tmp, file);
2809
+ renameSync3(tmp, file);
2513
2810
  } catch (err) {
2514
2811
  log.debug("could not write the subagent dispatch choice store", {
2515
2812
  error: err instanceof Error ? err.message : String(err)
@@ -3419,97 +3716,404 @@ function stripSubagentDispatchParts(prompt) {
3419
3716
  return changed ? out : prompt;
3420
3717
  }
3421
3718
 
3422
- // src/cli-version.ts
3423
- import { execFile } from "child_process";
3424
- import { promisify } from "util";
3425
-
3426
- // src/windows-spawn.ts
3427
- import * as fs2 from "fs";
3428
- import * as path4 from "path";
3429
- var DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
3430
- function envValue(env, name) {
3431
- const direct = env[name];
3432
- if (direct !== void 0) return direct;
3433
- const match = Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase());
3434
- return match === void 0 ? void 0 : env[match];
3719
+ // src/session-fork.ts
3720
+ import { createHash } from "crypto";
3721
+ var fingerprints = /* @__PURE__ */ new Map();
3722
+ var fingerprintSeq = 0;
3723
+ var MAX_FORK_FINGERPRINTS = 64;
3724
+ function hashPart(hash, part) {
3725
+ const p = part;
3726
+ const type = typeof p?.type === "string" ? p.type : "unknown";
3727
+ switch (type) {
3728
+ case "text":
3729
+ hash.update("text\0");
3730
+ hash.update(String(p?.text ?? ""));
3731
+ break;
3732
+ case "tool-call":
3733
+ hash.update("call\0");
3734
+ hash.update(String(p?.toolName ?? ""));
3735
+ hash.update("\0");
3736
+ hash.update(stableJson(p?.input));
3737
+ break;
3738
+ case "tool-result":
3739
+ hash.update("result\0");
3740
+ hash.update(String(p?.toolName ?? ""));
3741
+ hash.update("\0");
3742
+ hash.update(stableJson(p?.output));
3743
+ break;
3744
+ case "file":
3745
+ case "image": {
3746
+ hash.update(type);
3747
+ hash.update("\0");
3748
+ hash.update(String(p?.mediaType ?? ""));
3749
+ hash.update("\0");
3750
+ const data = p?.data ?? p?.image;
3751
+ if (typeof data === "string") hash.update(data);
3752
+ else if (data instanceof Uint8Array) hash.update(data);
3753
+ else if (data instanceof URL) hash.update(data.href);
3754
+ else hash.update(stableJson(data));
3755
+ break;
3756
+ }
3757
+ default:
3758
+ hash.update("other\0");
3759
+ hash.update(type);
3760
+ break;
3761
+ }
3762
+ hash.update("");
3435
3763
  }
3436
- function defaultIsFile(candidate) {
3764
+ function stableJson(value) {
3765
+ const seen = /* @__PURE__ */ new WeakSet();
3766
+ const walk = (node) => {
3767
+ if (node === null || typeof node !== "object") return node;
3768
+ if (seen.has(node)) return "[circular]";
3769
+ seen.add(node);
3770
+ if (Array.isArray(node)) return node.map(walk);
3771
+ const out = {};
3772
+ for (const key of Object.keys(node).sort()) {
3773
+ out[key] = walk(node[key]);
3774
+ }
3775
+ return out;
3776
+ };
3437
3777
  try {
3438
- return fs2.statSync(candidate).isFile();
3778
+ return JSON.stringify(walk(value)) ?? "null";
3439
3779
  } catch {
3440
- return false;
3780
+ return "[unserializable]";
3441
3781
  }
3442
3782
  }
3443
- function executableExtensions(env = process.env) {
3444
- const raw = envValue(env, "PATHEXT") ?? DEFAULT_PATHEXT;
3445
- return raw.split(";").map((ext) => ext.trim().toLowerCase()).filter(Boolean).map((ext) => ext.startsWith(".") ? ext : `.${ext}`);
3446
- }
3447
- function isBatchFile(file) {
3448
- const ext = path4.win32.extname(file).toLowerCase();
3449
- return ext === ".cmd" || ext === ".bat";
3450
- }
3451
- function resolveWindowsCommand(command, deps = {}) {
3452
- const env = deps.env ?? process.env;
3453
- const isFile = deps.isFile ?? defaultIsFile;
3454
- const extensions = executableExtensions(env);
3455
- const candidatesFor = (base) => {
3456
- const ext = path4.win32.extname(base).toLowerCase();
3457
- if (ext && extensions.includes(ext)) return [base];
3458
- return [...extensions.map((candidate) => base + candidate), base];
3459
- };
3460
- if (command.includes("\\") || command.includes("/")) {
3461
- for (const candidate of candidatesFor(command)) {
3462
- if (isFile(candidate)) return candidate;
3463
- }
3464
- return null;
3465
- }
3466
- const pathValue = envValue(env, "PATH") ?? "";
3467
- for (const entry of pathValue.split(path4.win32.delimiter)) {
3468
- const directory = entry.trim().replace(/^"(.*)"$/, "$1");
3469
- if (!directory) continue;
3470
- for (const candidate of candidatesFor(path4.win32.join(directory, command))) {
3471
- if (isFile(candidate)) return candidate;
3472
- }
3783
+ function messageDigest(msg) {
3784
+ const hash = createHash("sha256");
3785
+ hash.update(msg.role);
3786
+ hash.update("\0");
3787
+ if (typeof msg.content === "string") {
3788
+ hash.update("text\0");
3789
+ hash.update(msg.content);
3790
+ hash.update("");
3791
+ } else if (Array.isArray(msg.content)) {
3792
+ for (const part of msg.content) hashPart(hash, part);
3473
3793
  }
3474
- return null;
3794
+ return hash.digest("hex").slice(0, 32);
3475
3795
  }
3476
- function quoteBatchArgument(arg) {
3477
- if (/[\r\n]/.test(arg)) {
3478
- throw new Error("cmd.exe cannot carry a newline in an argument");
3479
- }
3480
- let out = '"';
3481
- let backslashes = 0;
3482
- for (const char of arg) {
3483
- if (char === "\\") {
3484
- backslashes += 1;
3485
- continue;
3486
- }
3487
- if (char === '"') {
3488
- out += "\\".repeat(backslashes * 2) + '""';
3489
- backslashes = 0;
3796
+ var ASSISTANT_TURN_DIGEST = "assistant-turn";
3797
+ function conversationDigests(prompt, opts) {
3798
+ const out = [];
3799
+ for (const msg of prompt) {
3800
+ if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "tool") {
3490
3801
  continue;
3491
3802
  }
3492
- out += "\\".repeat(backslashes) + char;
3493
- backslashes = 0;
3803
+ out.push({
3804
+ role: msg.role,
3805
+ digest: opts?.assistantContent === false && msg.role === "assistant" ? ASSISTANT_TURN_DIGEST : messageDigest(msg)
3806
+ });
3494
3807
  }
3495
- return `${out}${"\\".repeat(backslashes * 2)}"`;
3496
- }
3497
- function planCmdInvocation(file, args, env = process.env) {
3498
- const comspec = envValue(env, "ComSpec") ?? "cmd.exe";
3499
- const line = [`"${path4.win32.normalize(file)}"`, ...args.map(quoteBatchArgument)];
3500
- return {
3501
- file: comspec,
3502
- args: ["/d", "/s", "/c", `"${line.join(" ")}"`],
3503
- windowsVerbatimArguments: true
3504
- };
3505
- }
3506
- function planClaudeSpawn(command, args, deps = {}) {
3507
- const platform2 = deps.platform ?? process.platform;
3508
- if (platform2 !== "win32") return { file: command, args: [...args] };
3509
- const env = deps.env ?? process.env;
3510
- const resolved = resolveWindowsCommand(command, deps) ?? command;
3511
- if (isBatchFile(resolved)) return planCmdInvocation(resolved, args, env);
3512
- return { file: resolved, args: [...args] };
3808
+ return out;
3809
+ }
3810
+ function recordForkFingerprint(sessionKey2, prompt, cliPath) {
3811
+ const entries = conversationDigests(prompt);
3812
+ if (entries.length === 0) return;
3813
+ fingerprints.delete(sessionKey2);
3814
+ fingerprints.set(sessionKey2, { entries, cliPath, seq: ++fingerprintSeq });
3815
+ while (fingerprints.size > MAX_FORK_FINGERPRINTS) {
3816
+ const oldest = fingerprints.keys().next();
3817
+ if (oldest.done) break;
3818
+ fingerprints.delete(oldest.value);
3819
+ }
3820
+ }
3821
+ function forgetForkFingerprint(sessionKey2) {
3822
+ fingerprints.delete(sessionKey2);
3823
+ }
3824
+ function forkSiblingSignature(sessionKey2) {
3825
+ const parts = sessionKey2.split("::");
3826
+ if (parts.length < 5) return null;
3827
+ if (parts[2] === "compaction") return null;
3828
+ const blanked = [...parts];
3829
+ blanked[3] = "*";
3830
+ return blanked.join("::");
3831
+ }
3832
+ function modelSiblingSignature(sessionKey2, accountProviders) {
3833
+ const parts = sessionKey2.split("::");
3834
+ if (parts.length < 5) return null;
3835
+ if (parts[2] === "compaction") return null;
3836
+ const withoutEffort = parts[parts.length - 1].startsWith("effort=") ? parts.slice(0, -1) : parts;
3837
+ if (withoutEffort.length < 5) return null;
3838
+ const blanked = [...withoutEffort];
3839
+ blanked[1] = "*";
3840
+ const signature = blanked.join("::");
3841
+ return accountProviders && accountProviders.size > 0 ? blankAccountProvider(signature, accountProviders) : signature;
3842
+ }
3843
+ function moveForkFingerprint(fromKey, toKey) {
3844
+ const record = fingerprints.get(fromKey);
3845
+ if (!record) return;
3846
+ fingerprints.delete(fromKey);
3847
+ fingerprints.delete(toKey);
3848
+ fingerprints.set(toKey, { ...record, seq: ++fingerprintSeq });
3849
+ }
3850
+ function splitForkHistory(prompt, opts) {
3851
+ const messages = prompt;
3852
+ let lastAssistant = -1;
3853
+ for (let i = messages.length - 1; i >= 0; i--) {
3854
+ if (messages[i].role === "assistant") {
3855
+ lastAssistant = i;
3856
+ break;
3857
+ }
3858
+ }
3859
+ if (lastAssistant < 0) return { history: [], forkable: false };
3860
+ for (let i = lastAssistant + 1; i < messages.length; i++) {
3861
+ const msg = messages[i];
3862
+ if (msg.role === "tool") return { history: [], forkable: false };
3863
+ if (!Array.isArray(msg.content)) continue;
3864
+ for (const part of msg.content) {
3865
+ if (part?.type === "tool-result") return { history: [], forkable: false };
3866
+ }
3867
+ }
3868
+ return {
3869
+ history: conversationDigests(messages.slice(0, lastAssistant + 1), opts),
3870
+ forkable: true
3871
+ };
3872
+ }
3873
+ function findForkParent(opts) {
3874
+ const signature = forkSiblingSignature(opts.sessionKey);
3875
+ if (!signature) return null;
3876
+ const { history, forkable } = splitForkHistory(opts.prompt);
3877
+ if (!forkable || history.length === 0) return null;
3878
+ let best = null;
3879
+ let bestLength = 0;
3880
+ let bestSeq = -1;
3881
+ for (const [key, fingerprint] of fingerprints) {
3882
+ if (key === opts.sessionKey) continue;
3883
+ if (fingerprint.cliPath !== opts.cliPath) continue;
3884
+ if (forkSiblingSignature(key) !== signature) continue;
3885
+ const recorded = fingerprint.entries;
3886
+ if (recorded.length === 0 || recorded.length > history.length) continue;
3887
+ if (!isPrefix(recorded, history)) continue;
3888
+ if (!tailIsReplyOnly(history, recorded.length)) continue;
3889
+ const claudeSessionId = opts.lookupClaudeSessionId(key);
3890
+ if (!claudeSessionId) continue;
3891
+ if (opts.isBusy(key)) continue;
3892
+ if (recorded.length > bestLength || recorded.length === bestLength && fingerprint.seq > bestSeq) {
3893
+ best = { parentKey: key, claudeSessionId, matched: recorded.length };
3894
+ bestLength = recorded.length;
3895
+ bestSeq = fingerprint.seq;
3896
+ }
3897
+ }
3898
+ if (best) {
3899
+ log.info("found a fork parent for this conversation", {
3900
+ sessionKey: opts.sessionKey,
3901
+ parentKey: best.parentKey,
3902
+ matchedMessages: best.matched,
3903
+ historyMessages: history.length
3904
+ });
3905
+ }
3906
+ return best;
3907
+ }
3908
+ function continuesRecordedConversation(recorded, prompt, opts) {
3909
+ const { history, forkable } = splitForkHistory(prompt, opts);
3910
+ if (!forkable || recorded.length === 0 || recorded.length > history.length) return false;
3911
+ return isPrefix(recorded, history) && tailIsReplyOnly(history, recorded.length);
3912
+ }
3913
+ function isPrefix(recorded, history) {
3914
+ for (let i = 0; i < recorded.length; i++) {
3915
+ if (recorded[i].role !== history[i].role) return false;
3916
+ if (recorded[i].digest !== history[i].digest) return false;
3917
+ }
3918
+ return true;
3919
+ }
3920
+ function tailIsReplyOnly(history, from) {
3921
+ for (let i = from; i < history.length; i++) {
3922
+ if (history[i].role === "user") return false;
3923
+ }
3924
+ return true;
3925
+ }
3926
+
3927
+ // src/conversation-gap.ts
3928
+ var served = /* @__PURE__ */ new Map();
3929
+ var servedSeq = 0;
3930
+ var MAX_SERVED_CONVERSATIONS = 64;
3931
+ function recordServedConversation(sessionKey2, prompt) {
3932
+ const chain = conversationDigests(prompt, { assistantContent: false });
3933
+ if (chain.length === 0) return;
3934
+ served.delete(sessionKey2);
3935
+ served.set(sessionKey2, { chain, seq: ++servedSeq });
3936
+ while (served.size > MAX_SERVED_CONVERSATIONS) {
3937
+ const oldest = served.keys().next();
3938
+ if (oldest.done) break;
3939
+ served.delete(oldest.value);
3940
+ }
3941
+ }
3942
+ function forgetServedConversation(sessionKey2) {
3943
+ served.delete(sessionKey2);
3944
+ }
3945
+ function moveServedConversation(fromKey, toKey) {
3946
+ const record = served.get(fromKey);
3947
+ if (!record) return;
3948
+ served.delete(fromKey);
3949
+ served.delete(toKey);
3950
+ served.set(toKey, { ...record, seq: ++servedSeq });
3951
+ }
3952
+ function classifyConversationChange(sessionKey2, prompt) {
3953
+ const record = served.get(sessionKey2);
3954
+ if (!record) return null;
3955
+ const recorded = record.chain;
3956
+ if (recorded.length === 0) return null;
3957
+ const messages = prompt;
3958
+ let lastAssistant = -1;
3959
+ for (let i = messages.length - 1; i >= 0; i--) {
3960
+ if (messages[i].role === "assistant") {
3961
+ lastAssistant = i;
3962
+ break;
3963
+ }
3964
+ }
3965
+ if (lastAssistant < 0) return null;
3966
+ const history = [];
3967
+ for (let i = 0; i <= lastAssistant; i++) {
3968
+ const msg = messages[i];
3969
+ if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "tool") continue;
3970
+ const [entry] = conversationDigests([msg], { assistantContent: false });
3971
+ history.push({ index: i, role: entry.role, digest: entry.digest });
3972
+ }
3973
+ let shared = 0;
3974
+ while (shared < recorded.length && shared < history.length && recorded[shared].role === history[shared].role && recorded[shared].digest === history[shared].digest) {
3975
+ shared++;
3976
+ }
3977
+ if (shared < recorded.length) {
3978
+ if (shared === 0) return null;
3979
+ let removedUserMessages = 0;
3980
+ for (let i = shared; i < recorded.length; i++) {
3981
+ if (recorded[i].role === "user") removedUserMessages++;
3982
+ }
3983
+ if (removedUserMessages === 0) return null;
3984
+ log.debug("this claude session holds turns the conversation no longer has", {
3985
+ sessionKey: sessionKey2,
3986
+ recorded: recorded.length,
3987
+ shared,
3988
+ removedUserMessages
3989
+ });
3990
+ return {
3991
+ kind: "rewound",
3992
+ sharedMessages: shared,
3993
+ removedMessages: recorded.length - shared,
3994
+ removedUserMessages
3995
+ };
3996
+ }
3997
+ let firstUser = -1;
3998
+ for (let i = recorded.length; i < history.length; i++) {
3999
+ if (history[i].role === "user") {
4000
+ firstUser = i;
4001
+ break;
4002
+ }
4003
+ }
4004
+ if (firstUser < 0) return null;
4005
+ const from = history[firstUser].index;
4006
+ const missed = messages.slice(from, lastAssistant + 1);
4007
+ let userMessages = 0;
4008
+ for (let i = firstUser; i < history.length; i++) {
4009
+ if (history[i].role === "user") userMessages++;
4010
+ }
4011
+ log.debug("this claude session missed part of the conversation", {
4012
+ sessionKey: sessionKey2,
4013
+ recorded: recorded.length,
4014
+ missed: missed.length,
4015
+ userMessages
4016
+ });
4017
+ return { kind: "missed", messages: missed, userMessages };
4018
+ }
4019
+ var CONVERSATION_REWOUND_MARKER = "\u258C **conversation rewound:**";
4020
+ function formatConversationRewoundNote() {
4021
+ return `
4022
+ ${CONVERSATION_REWOUND_MARKER} this conversation was edited or undone, so the Claude session answering it still held the turns that were removed. This turn replays the thread as it stands now into a fresh Claude session, which no longer has them.
4023
+ `;
4024
+ }
4025
+
4026
+ // src/cli-version.ts
4027
+ import { execFile } from "child_process";
4028
+ import { promisify } from "util";
4029
+
4030
+ // src/windows-spawn.ts
4031
+ import * as fs2 from "fs";
4032
+ import * as path4 from "path";
4033
+ var DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
4034
+ function envValue(env, name) {
4035
+ const direct = env[name];
4036
+ if (direct !== void 0) return direct;
4037
+ const match = Object.keys(env).find((key) => key.toLowerCase() === name.toLowerCase());
4038
+ return match === void 0 ? void 0 : env[match];
4039
+ }
4040
+ function defaultIsFile(candidate) {
4041
+ try {
4042
+ return fs2.statSync(candidate).isFile();
4043
+ } catch {
4044
+ return false;
4045
+ }
4046
+ }
4047
+ function executableExtensions(env = process.env) {
4048
+ const raw = envValue(env, "PATHEXT") ?? DEFAULT_PATHEXT;
4049
+ return raw.split(";").map((ext) => ext.trim().toLowerCase()).filter(Boolean).map((ext) => ext.startsWith(".") ? ext : `.${ext}`);
4050
+ }
4051
+ function isBatchFile(file) {
4052
+ const ext = path4.win32.extname(file).toLowerCase();
4053
+ return ext === ".cmd" || ext === ".bat";
4054
+ }
4055
+ function resolveWindowsCommand(command, deps = {}) {
4056
+ const env = deps.env ?? process.env;
4057
+ const isFile = deps.isFile ?? defaultIsFile;
4058
+ const extensions = executableExtensions(env);
4059
+ const candidatesFor = (base) => {
4060
+ const ext = path4.win32.extname(base).toLowerCase();
4061
+ if (ext && extensions.includes(ext)) return [base];
4062
+ return [...extensions.map((candidate) => base + candidate), base];
4063
+ };
4064
+ if (command.includes("\\") || command.includes("/")) {
4065
+ for (const candidate of candidatesFor(command)) {
4066
+ if (isFile(candidate)) return candidate;
4067
+ }
4068
+ return null;
4069
+ }
4070
+ const pathValue = envValue(env, "PATH") ?? "";
4071
+ for (const entry of pathValue.split(path4.win32.delimiter)) {
4072
+ const directory = entry.trim().replace(/^"(.*)"$/, "$1");
4073
+ if (!directory) continue;
4074
+ for (const candidate of candidatesFor(path4.win32.join(directory, command))) {
4075
+ if (isFile(candidate)) return candidate;
4076
+ }
4077
+ }
4078
+ return null;
4079
+ }
4080
+ function quoteBatchArgument(arg) {
4081
+ if (/[\r\n]/.test(arg)) {
4082
+ throw new Error("cmd.exe cannot carry a newline in an argument");
4083
+ }
4084
+ let out = '"';
4085
+ let backslashes = 0;
4086
+ for (const char of arg) {
4087
+ if (char === "\\") {
4088
+ backslashes += 1;
4089
+ continue;
4090
+ }
4091
+ if (char === '"') {
4092
+ out += "\\".repeat(backslashes * 2) + '""';
4093
+ backslashes = 0;
4094
+ continue;
4095
+ }
4096
+ out += "\\".repeat(backslashes) + char;
4097
+ backslashes = 0;
4098
+ }
4099
+ return `${out}${"\\".repeat(backslashes * 2)}"`;
4100
+ }
4101
+ function planCmdInvocation(file, args, env = process.env) {
4102
+ const comspec = envValue(env, "ComSpec") ?? "cmd.exe";
4103
+ const line = [`"${path4.win32.normalize(file)}"`, ...args.map(quoteBatchArgument)];
4104
+ return {
4105
+ file: comspec,
4106
+ args: ["/d", "/s", "/c", `"${line.join(" ")}"`],
4107
+ windowsVerbatimArguments: true
4108
+ };
4109
+ }
4110
+ function planClaudeSpawn(command, args, deps = {}) {
4111
+ const platform2 = deps.platform ?? process.platform;
4112
+ if (platform2 !== "win32") return { file: command, args: [...args] };
4113
+ const env = deps.env ?? process.env;
4114
+ const resolved = resolveWindowsCommand(command, deps) ?? command;
4115
+ if (isBatchFile(resolved)) return planCmdInvocation(resolved, args, env);
4116
+ return { file: resolved, args: [...args] };
3513
4117
  }
3514
4118
 
3515
4119
  // src/cli-version.ts
@@ -3524,7 +4128,7 @@ function probeExec(cliPath, args, options) {
3524
4128
  windowsVerbatimArguments: plan.windowsVerbatimArguments
3525
4129
  });
3526
4130
  }
3527
- var cache = /* @__PURE__ */ new Map();
4131
+ var cache2 = /* @__PURE__ */ new Map();
3528
4132
  var PROBE_TIMEOUT_MS = 5e3;
3529
4133
  var probeTimeoutMs = PROBE_TIMEOUT_MS;
3530
4134
  var MAX_DEADLINE_REPROBES = 2;
@@ -3569,8 +4173,8 @@ function spawnMcpTimeoutEnv(inherited = process.env) {
3569
4173
  return inherited.MCP_TIMEOUT === void 0 ? { MCP_TIMEOUT: String(SPAWN_MCP_TIMEOUT_MS) } : {};
3570
4174
  }
3571
4175
  function detectCliVersion(cliPath) {
3572
- const cached = cache.get(cliPath);
3573
- if (cached) return cached;
4176
+ const cached2 = cache2.get(cliPath);
4177
+ if (cached2) return cached2;
3574
4178
  let deadlineKill = false;
3575
4179
  const promise = (async () => {
3576
4180
  try {
@@ -3606,8 +4210,8 @@ function detectCliVersion(cliPath) {
3606
4210
  return null;
3607
4211
  }
3608
4212
  })();
3609
- cache.set(cliPath, promise);
3610
- forgetDeadlineKill(cache, cliPath, promise, () => deadlineKill);
4213
+ cache2.set(cliPath, promise);
4214
+ forgetDeadlineKill(cache2, cliPath, promise, () => deadlineKill);
3611
4215
  return promise;
3612
4216
  }
3613
4217
  function gte(v, target) {
@@ -3639,6 +4243,10 @@ function cliSupportsInteractiveBypass(v) {
3639
4243
  if (!v) return false;
3640
4244
  return gte(v, { major: 2, minor: 1, patch: 263 });
3641
4245
  }
4246
+ function cliSupportsMaxTurns(v) {
4247
+ if (!v) return false;
4248
+ return gte(v, { major: 2, minor: 1, patch: 280 });
4249
+ }
3642
4250
  var INTERACTIVE_MEASURED_CLI = "2.1.293";
3643
4251
  var INTERACTIVE_MEASURED = { major: 2, minor: 1, patch: 293 };
3644
4252
  function isUnmeasuredInteractiveCli(v) {
@@ -3666,8 +4274,8 @@ var flagSupport = /* @__PURE__ */ new Map();
3666
4274
  var headlessSupport = /* @__PURE__ */ new Map();
3667
4275
  function detectHeadlessSupport(cliPath) {
3668
4276
  const key = `${cliPath}\0headless`;
3669
- const cached = headlessSupport.get(key);
3670
- if (cached) return cached;
4277
+ const cached2 = headlessSupport.get(key);
4278
+ if (cached2) return cached2;
3671
4279
  let deadlineKill = false;
3672
4280
  const promise = (async () => {
3673
4281
  try {
@@ -3702,8 +4310,8 @@ function detectHeadlessSupport(cliPath) {
3702
4310
  }
3703
4311
  function detectCliSupportsFlag(cliPath, flag) {
3704
4312
  const key = `${cliPath}\0${flag}`;
3705
- const cached = flagSupport.get(key);
3706
- if (cached) return cached;
4313
+ const cached2 = flagSupport.get(key);
4314
+ if (cached2) return cached2;
3707
4315
  let deadlineKill = false;
3708
4316
  const promise = (async () => {
3709
4317
  try {
@@ -4383,6 +4991,21 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
4383
4991
  writeToolCallResult(res, requestId, intercepted);
4384
4992
  return;
4385
4993
  }
4994
+ if (options.admit) {
4995
+ let refusal = null;
4996
+ try {
4997
+ refusal = await options.admit(toolName, input);
4998
+ } catch (admitError) {
4999
+ log.warn("proxy-mcp admission check failed; letting the call through", {
5000
+ toolName,
5001
+ error: admitError instanceof Error ? admitError.message : String(admitError)
5002
+ });
5003
+ }
5004
+ if (refusal) {
5005
+ writeToolCallResult(res, requestId, refusal);
5006
+ return;
5007
+ }
5008
+ }
4386
5009
  const callId = crypto.randomUUID();
4387
5010
  log.info("proxy-mcp tool call received", {
4388
5011
  callId,
@@ -4403,12 +5026,12 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
4403
5026
  });
4404
5027
  let timer = null;
4405
5028
  const result = await new Promise(
4406
- (resolve6, reject2) => {
5029
+ (resolve7, reject2) => {
4407
5030
  const entry = {
4408
5031
  id: callId,
4409
5032
  toolName,
4410
5033
  input,
4411
- resolve: resolve6,
5034
+ resolve: resolve7,
4412
5035
  reject: reject2,
4413
5036
  channel
4414
5037
  };
@@ -4497,11 +5120,11 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
4497
5120
  }
4498
5121
  }
4499
5122
  });
4500
- await new Promise((resolve6, reject2) => {
5123
+ await new Promise((resolve7, reject2) => {
4501
5124
  server2.once("error", reject2);
4502
5125
  server2.listen(0, "127.0.0.1", () => {
4503
5126
  server2.off("error", reject2);
4504
- resolve6();
5127
+ resolve7();
4505
5128
  });
4506
5129
  });
4507
5130
  const addr = server2.address();
@@ -4558,8 +5181,8 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
4558
5181
  entry.reject(new Error(SERVER_CLOSED_MESSAGE));
4559
5182
  }
4560
5183
  pending2.clear();
4561
- await new Promise((resolve6) => {
4562
- server2.close(() => resolve6());
5184
+ await new Promise((resolve7) => {
5185
+ server2.close(() => resolve7());
4563
5186
  });
4564
5187
  if (configFilePath) {
4565
5188
  try {
@@ -4615,6 +5238,7 @@ function resolveDisallowedTools(options) {
4615
5238
  for (const name of disallowedToolFlags(options.proxyTools ?? [])) push(name);
4616
5239
  for (const name of options.extraDisallowedTools ?? []) push(String(name));
4617
5240
  if (options.disableWebSearch) push("WebSearch");
5241
+ for (const rule of options.readRules ?? []) push(String(rule));
4618
5242
  return out;
4619
5243
  }
4620
5244
  function resolveProxyOpencodeToolDefs(options) {
@@ -4731,10 +5355,10 @@ function resolveMcpProxyToolDefs(options) {
4731
5355
  return { defs, coveredServers, matchedServers };
4732
5356
  }
4733
5357
  function readBody(req) {
4734
- return new Promise((resolve6, reject) => {
5358
+ return new Promise((resolve7, reject) => {
4735
5359
  const chunks = [];
4736
5360
  req.on("data", (chunk) => chunks.push(chunk));
4737
- req.on("end", () => resolve6(Buffer.concat(chunks).toString("utf8")));
5361
+ req.on("end", () => resolve7(Buffer.concat(chunks).toString("utf8")));
4738
5362
  req.on("error", reject);
4739
5363
  });
4740
5364
  }
@@ -5623,6 +6247,7 @@ var opencodeClient = null;
5623
6247
  function setOpencodeClient(client) {
5624
6248
  if (client && typeof client === "object") {
5625
6249
  opencodeClient = client;
6250
+ childSessionAnswers.clear();
5626
6251
  }
5627
6252
  }
5628
6253
  function getOpencodeClient() {
@@ -5690,6 +6315,20 @@ async function fetchSessionParentId(sessionID) {
5690
6315
  return void 0;
5691
6316
  }
5692
6317
  }
6318
+ var childSessionAnswers = /* @__PURE__ */ new Map();
6319
+ var MAX_CHILD_SESSION_ANSWERS = 256;
6320
+ function watchChildSession(sessionID) {
6321
+ if (!sessionID || sessionID === "default") return () => false;
6322
+ const known = childSessionAnswers.get(sessionID);
6323
+ if (known !== void 0) return () => known;
6324
+ let answer;
6325
+ void fetchSessionParentId(sessionID).then((parentID) => {
6326
+ answer = !!parentID;
6327
+ if (childSessionAnswers.size >= MAX_CHILD_SESSION_ANSWERS) childSessionAnswers.clear();
6328
+ childSessionAnswers.set(sessionID, answer);
6329
+ });
6330
+ return () => answer;
6331
+ }
5693
6332
  async function fetchSessionRunState(sessionID, directory) {
5694
6333
  if (!sessionID || sessionID === "default") return "unknown";
5695
6334
  const status = opencodeClient?.session?.status;
@@ -5726,7 +6365,7 @@ async function settleSessionRunState(sessionID, options = {}) {
5726
6365
  if (state === "busy") return "busy";
5727
6366
  if (state !== "unknown") last = state;
5728
6367
  if (Date.now() - started2 >= windowMs) return last;
5729
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
6368
+ await new Promise((resolve7) => setTimeout(resolve7, pollMs));
5730
6369
  }
5731
6370
  }
5732
6371
  var DEFAULT_MCP_CONNECT_WAIT_MS = 3e3;
@@ -5779,7 +6418,7 @@ async function waitForPendingMcpServers(first, budgetMs, signal) {
5779
6418
  for (; ; ) {
5780
6419
  if (Date.now() - started2 >= budgetMs) break;
5781
6420
  if (signal?.aborted) break;
5782
- await new Promise((resolve6) => setTimeout(resolve6, MCP_CONNECT_POLL_MS));
6421
+ await new Promise((resolve7) => setTimeout(resolve7, MCP_CONNECT_POLL_MS));
5783
6422
  if (signal?.aborted) break;
5784
6423
  const next = await fetchRuntimeMcpStatus();
5785
6424
  if (!next) break;
@@ -6008,8 +6647,8 @@ async function countOtherRunningWithin(exclude, budgetMs) {
6008
6647
  worker
6009
6648
  );
6010
6649
  let timer;
6011
- const deadline = new Promise((resolve6) => {
6012
- timer = setTimeout(() => resolve6("over"), budgetMs);
6650
+ const deadline = new Promise((resolve7) => {
6651
+ timer = setTimeout(() => resolve7("over"), budgetMs);
6013
6652
  timer.unref?.();
6014
6653
  });
6015
6654
  try {
@@ -6040,6 +6679,41 @@ async function withBackgroundRunningCount(result, budgetMs = BACKGROUND_COUNT_BU
6040
6679
  }
6041
6680
  return { ...result, text: result.text + formatBackgroundRunningLine(others, dispatched.length) };
6042
6681
  }
6682
+ var BACKGROUND_CAP_BUDGET_MS = 2e3;
6683
+ function requestedBackgroundChildren(toolName, input) {
6684
+ const isBackground = (value) => value === true || value === "true";
6685
+ if (toolName === "task") return isBackground(input.background) ? 1 : 0;
6686
+ if (toolName === "task_batch" && Array.isArray(input.tasks)) {
6687
+ return input.tasks.filter(
6688
+ (task) => !!task && typeof task === "object" && isBackground(task.background)
6689
+ ).length;
6690
+ }
6691
+ return 0;
6692
+ }
6693
+ async function admitBackgroundDispatch(toolName, input, cap, budgetMs = BACKGROUND_CAP_BUDGET_MS) {
6694
+ const requested = requestedBackgroundChildren(toolName, input);
6695
+ if (requested === 0) return null;
6696
+ const running = await countOtherRunningWithin(/* @__PURE__ */ new Set(), budgetMs);
6697
+ if (running !== void 0 && running + requested <= cap) return null;
6698
+ log.notice("background dispatch refused by maxBackgroundSubagents", {
6699
+ toolName,
6700
+ cap,
6701
+ requested,
6702
+ ...running === void 0 ? { countUnreadable: true } : { running }
6703
+ });
6704
+ return {
6705
+ kind: "error",
6706
+ message: formatBackgroundCapRefusal({ toolName, cap, running, requested, budgetMs })
6707
+ };
6708
+ }
6709
+ function formatBackgroundCapRefusal(opts) {
6710
+ const batch = opts.toolName === "task_batch";
6711
+ const what = batch ? `This task_batch was not started, none of its tasks: a batch is refused whole.` : `This background task was not started.`;
6712
+ const why = opts.running === void 0 ? `\`maxBackgroundSubagents\` allows ${opts.cap} background subagent${opts.cap === 1 ? "" : "s"} at once, and the running count could not be read within ${Math.round(
6713
+ (opts.budgetMs ?? BACKGROUND_CAP_BUDGET_MS) / 1e3
6714
+ )} s, so the cap could not be checked.` : `\`maxBackgroundSubagents\` allows ${opts.cap} background subagent${opts.cap === 1 ? "" : "s"} at once, ${opts.running} ${opts.running === 1 ? "is" : "are"} running now, and this ${batch ? "batch" : "dispatch"} would start ${opts.requested} more.`;
6715
+ return `Background dispatch refused. ${why} ${what} Run this work in the foreground instead (omit \`background\`), which is never capped, or keep working and dispatch it later: a running subagent's result arrives as a new message when it finishes. Do not sleep or poll for room.`;
6716
+ }
6043
6717
  function formatBackgroundRunningLine(others, dispatched) {
6044
6718
  const total = others + dispatched;
6045
6719
  const including = dispatched === 1 ? "this one" : `the ${dispatched} just started`;
@@ -6121,288 +6795,80 @@ opencode has no assistant reply for this task yet and it is not running. It may
6121
6795
  };
6122
6796
  }
6123
6797
  if (reply.error) {
6124
- markCollected(options.sessionKey, taskId);
6125
- return {
6126
- kind: "text",
6127
- isError: true,
6128
- text: `<task id="${taskId}" state="error">
6129
- ${reply.error}
6130
- </task>`
6131
- };
6132
- }
6133
- if (hasCollectedBackgroundTask(options.sessionKey, taskId)) {
6134
- return {
6135
- kind: "text",
6136
- text: `<task id="${taskId}" state="completed">
6137
- Already delivered to this conversation earlier (by this tool or by opencode's completion notification). Scroll back for the result rather than collecting it again.
6138
- </task>`
6139
- };
6140
- }
6141
- markCollected(options.sessionKey, taskId);
6142
- log.info("collected background subagent result", {
6143
- sessionKey: options.sessionKey,
6144
- taskId,
6145
- textLength: reply.text.length
6146
- });
6147
- return {
6148
- kind: "text",
6149
- text: `<task id="${taskId}" state="completed">
6150
- <task_result>
6151
- ${reply.text || "(the subagent produced no text)"}
6152
- </task_result>
6153
- </task>`
6154
- };
6155
- }
6156
- async function cancelBackgroundTask(input, options) {
6157
- const taskId = readTaskId(input);
6158
- if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
6159
- const refused = await guardParent(taskId, options.callerSessionId);
6160
- if (refused) return { kind: "error", message: refused };
6161
- const running = isBackgroundTaskRunning(
6162
- await fetchSessionRunState(taskId),
6163
- await fetchSessionReplies(taskId) ?? []
6164
- );
6165
- const aborted = await abortSession(taskId);
6166
- if (!aborted) {
6167
- return {
6168
- kind: "error",
6169
- message: `Could not cancel background task ${taskId}: opencode did not accept the abort. It may have already finished, or this opencode build does not expose session abort.`
6170
- };
6171
- }
6172
- collected.get(options.sessionKey)?.delete(taskId);
6173
- mark(cancelled, options.sessionKey, taskId);
6174
- log.info("cancelled background subagent", {
6175
- sessionKey: options.sessionKey,
6176
- taskId,
6177
- wasRunning: running
6178
- });
6179
- return {
6180
- kind: "text",
6181
- text: `<task id="${taskId}" state="cancelled">
6182
- ` + (running ? "Stopped. No completion notification will arrive for it." : "It was not running when the cancel was sent, so nothing was interrupted. No completion notification will arrive for it.") + "\n</task>"
6183
- };
6184
- }
6185
-
6186
- // src/session-fork.ts
6187
- import { createHash as createHash3 } from "crypto";
6188
- var fingerprints = /* @__PURE__ */ new Map();
6189
- var fingerprintSeq = 0;
6190
- var MAX_FORK_FINGERPRINTS = 64;
6191
- function hashPart(hash, part) {
6192
- const p = part;
6193
- const type = typeof p?.type === "string" ? p.type : "unknown";
6194
- switch (type) {
6195
- case "text":
6196
- hash.update("text\0");
6197
- hash.update(String(p?.text ?? ""));
6198
- break;
6199
- case "tool-call":
6200
- hash.update("call\0");
6201
- hash.update(String(p?.toolName ?? ""));
6202
- hash.update("\0");
6203
- hash.update(stableJson(p?.input));
6204
- break;
6205
- case "tool-result":
6206
- hash.update("result\0");
6207
- hash.update(String(p?.toolName ?? ""));
6208
- hash.update("\0");
6209
- hash.update(stableJson(p?.output));
6210
- break;
6211
- case "file":
6212
- case "image": {
6213
- hash.update(type);
6214
- hash.update("\0");
6215
- hash.update(String(p?.mediaType ?? ""));
6216
- hash.update("\0");
6217
- const data = p?.data ?? p?.image;
6218
- if (typeof data === "string") hash.update(data);
6219
- else if (data instanceof Uint8Array) hash.update(data);
6220
- else if (data instanceof URL) hash.update(data.href);
6221
- else hash.update(stableJson(data));
6222
- break;
6223
- }
6224
- default:
6225
- hash.update("other\0");
6226
- hash.update(type);
6227
- break;
6228
- }
6229
- hash.update("");
6230
- }
6231
- function stableJson(value) {
6232
- const seen = /* @__PURE__ */ new WeakSet();
6233
- const walk = (node) => {
6234
- if (node === null || typeof node !== "object") return node;
6235
- if (seen.has(node)) return "[circular]";
6236
- seen.add(node);
6237
- if (Array.isArray(node)) return node.map(walk);
6238
- const out = {};
6239
- for (const key of Object.keys(node).sort()) {
6240
- out[key] = walk(node[key]);
6241
- }
6242
- return out;
6243
- };
6244
- try {
6245
- return JSON.stringify(walk(value)) ?? "null";
6246
- } catch {
6247
- return "[unserializable]";
6248
- }
6249
- }
6250
- function messageDigest(msg) {
6251
- const hash = createHash3("sha256");
6252
- hash.update(msg.role);
6253
- hash.update("\0");
6254
- if (typeof msg.content === "string") {
6255
- hash.update("text\0");
6256
- hash.update(msg.content);
6257
- hash.update("");
6258
- } else if (Array.isArray(msg.content)) {
6259
- for (const part of msg.content) hashPart(hash, part);
6260
- }
6261
- return hash.digest("hex").slice(0, 32);
6262
- }
6263
- var ASSISTANT_TURN_DIGEST = "assistant-turn";
6264
- function conversationDigests(prompt, opts) {
6265
- const out = [];
6266
- for (const msg of prompt) {
6267
- if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "tool") {
6268
- continue;
6269
- }
6270
- out.push({
6271
- role: msg.role,
6272
- digest: opts?.assistantContent === false && msg.role === "assistant" ? ASSISTANT_TURN_DIGEST : messageDigest(msg)
6273
- });
6274
- }
6275
- return out;
6276
- }
6277
- function recordForkFingerprint(sessionKey2, prompt, cliPath) {
6278
- const entries = conversationDigests(prompt);
6279
- if (entries.length === 0) return;
6280
- fingerprints.delete(sessionKey2);
6281
- fingerprints.set(sessionKey2, { entries, cliPath, seq: ++fingerprintSeq });
6282
- while (fingerprints.size > MAX_FORK_FINGERPRINTS) {
6283
- const oldest = fingerprints.keys().next();
6284
- if (oldest.done) break;
6285
- fingerprints.delete(oldest.value);
6286
- }
6287
- }
6288
- function forgetForkFingerprint(sessionKey2) {
6289
- fingerprints.delete(sessionKey2);
6290
- }
6291
- function forkSiblingSignature(sessionKey2) {
6292
- const parts = sessionKey2.split("::");
6293
- if (parts.length < 5) return null;
6294
- if (parts[2] === "compaction") return null;
6295
- const blanked = [...parts];
6296
- blanked[3] = "*";
6297
- return blanked.join("::");
6298
- }
6299
- function modelSiblingSignature(sessionKey2, accountProviders) {
6300
- const parts = sessionKey2.split("::");
6301
- if (parts.length < 5) return null;
6302
- if (parts[2] === "compaction") return null;
6303
- const withoutEffort = parts[parts.length - 1].startsWith("effort=") ? parts.slice(0, -1) : parts;
6304
- if (withoutEffort.length < 5) return null;
6305
- const blanked = [...withoutEffort];
6306
- blanked[1] = "*";
6307
- const signature = blanked.join("::");
6308
- return accountProviders && accountProviders.size > 0 ? blankAccountProvider(signature, accountProviders) : signature;
6309
- }
6310
- function moveForkFingerprint(fromKey, toKey) {
6311
- const record = fingerprints.get(fromKey);
6312
- if (!record) return;
6313
- fingerprints.delete(fromKey);
6314
- fingerprints.delete(toKey);
6315
- fingerprints.set(toKey, { ...record, seq: ++fingerprintSeq });
6316
- }
6317
- function splitForkHistory(prompt, opts) {
6318
- const messages = prompt;
6319
- let lastAssistant = -1;
6320
- for (let i = messages.length - 1; i >= 0; i--) {
6321
- if (messages[i].role === "assistant") {
6322
- lastAssistant = i;
6323
- break;
6324
- }
6798
+ markCollected(options.sessionKey, taskId);
6799
+ return {
6800
+ kind: "text",
6801
+ isError: true,
6802
+ text: `<task id="${taskId}" state="error">
6803
+ ${reply.error}
6804
+ </task>`
6805
+ };
6325
6806
  }
6326
- if (lastAssistant < 0) return { history: [], forkable: false };
6327
- for (let i = lastAssistant + 1; i < messages.length; i++) {
6328
- const msg = messages[i];
6329
- if (msg.role === "tool") return { history: [], forkable: false };
6330
- if (!Array.isArray(msg.content)) continue;
6331
- for (const part of msg.content) {
6332
- if (part?.type === "tool-result") return { history: [], forkable: false };
6333
- }
6807
+ if (hasCollectedBackgroundTask(options.sessionKey, taskId)) {
6808
+ return {
6809
+ kind: "text",
6810
+ text: `<task id="${taskId}" state="completed">
6811
+ Already delivered to this conversation earlier (by this tool or by opencode's completion notification). Scroll back for the result rather than collecting it again.
6812
+ </task>`
6813
+ };
6334
6814
  }
6815
+ markCollected(options.sessionKey, taskId);
6816
+ log.info("collected background subagent result", {
6817
+ sessionKey: options.sessionKey,
6818
+ taskId,
6819
+ textLength: reply.text.length
6820
+ });
6335
6821
  return {
6336
- history: conversationDigests(messages.slice(0, lastAssistant + 1), opts),
6337
- forkable: true
6822
+ kind: "text",
6823
+ text: `<task id="${taskId}" state="completed">
6824
+ <task_result>
6825
+ ${reply.text || "(the subagent produced no text)"}
6826
+ </task_result>
6827
+ </task>`
6338
6828
  };
6339
6829
  }
6340
- function findForkParent(opts) {
6341
- const signature = forkSiblingSignature(opts.sessionKey);
6342
- if (!signature) return null;
6343
- const { history, forkable } = splitForkHistory(opts.prompt);
6344
- if (!forkable || history.length === 0) return null;
6345
- let best = null;
6346
- let bestLength = 0;
6347
- let bestSeq = -1;
6348
- for (const [key, fingerprint] of fingerprints) {
6349
- if (key === opts.sessionKey) continue;
6350
- if (fingerprint.cliPath !== opts.cliPath) continue;
6351
- if (forkSiblingSignature(key) !== signature) continue;
6352
- const recorded = fingerprint.entries;
6353
- if (recorded.length === 0 || recorded.length > history.length) continue;
6354
- if (!isPrefix(recorded, history)) continue;
6355
- if (!tailIsReplyOnly(history, recorded.length)) continue;
6356
- const claudeSessionId = opts.lookupClaudeSessionId(key);
6357
- if (!claudeSessionId) continue;
6358
- if (opts.isBusy(key)) continue;
6359
- if (recorded.length > bestLength || recorded.length === bestLength && fingerprint.seq > bestSeq) {
6360
- best = { parentKey: key, claudeSessionId, matched: recorded.length };
6361
- bestLength = recorded.length;
6362
- bestSeq = fingerprint.seq;
6363
- }
6364
- }
6365
- if (best) {
6366
- log.info("found a fork parent for this conversation", {
6367
- sessionKey: opts.sessionKey,
6368
- parentKey: best.parentKey,
6369
- matchedMessages: best.matched,
6370
- historyMessages: history.length
6371
- });
6372
- }
6373
- return best;
6374
- }
6375
- function continuesRecordedConversation(recorded, prompt, opts) {
6376
- const { history, forkable } = splitForkHistory(prompt, opts);
6377
- if (!forkable || recorded.length === 0 || recorded.length > history.length) return false;
6378
- return isPrefix(recorded, history) && tailIsReplyOnly(history, recorded.length);
6379
- }
6380
- function isPrefix(recorded, history) {
6381
- for (let i = 0; i < recorded.length; i++) {
6382
- if (recorded[i].role !== history[i].role) return false;
6383
- if (recorded[i].digest !== history[i].digest) return false;
6384
- }
6385
- return true;
6386
- }
6387
- function tailIsReplyOnly(history, from) {
6388
- for (let i = from; i < history.length; i++) {
6389
- if (history[i].role === "user") return false;
6830
+ async function cancelBackgroundTask(input, options) {
6831
+ const taskId = readTaskId(input);
6832
+ if (!taskId) return { kind: "error", message: MISSING_TASK_ID };
6833
+ const refused = await guardParent(taskId, options.callerSessionId);
6834
+ if (refused) return { kind: "error", message: refused };
6835
+ const running = isBackgroundTaskRunning(
6836
+ await fetchSessionRunState(taskId),
6837
+ await fetchSessionReplies(taskId) ?? []
6838
+ );
6839
+ const aborted = await abortSession(taskId);
6840
+ if (!aborted) {
6841
+ return {
6842
+ kind: "error",
6843
+ message: `Could not cancel background task ${taskId}: opencode did not accept the abort. It may have already finished, or this opencode build does not expose session abort.`
6844
+ };
6390
6845
  }
6391
- return true;
6846
+ collected.get(options.sessionKey)?.delete(taskId);
6847
+ mark(cancelled, options.sessionKey, taskId);
6848
+ log.info("cancelled background subagent", {
6849
+ sessionKey: options.sessionKey,
6850
+ taskId,
6851
+ wasRunning: running
6852
+ });
6853
+ return {
6854
+ kind: "text",
6855
+ text: `<task id="${taskId}" state="cancelled">
6856
+ ` + (running ? "Stopped. No completion notification will arrive for it." : "It was not running when the cancel was sent, so nothing was interrupted. No completion notification will arrive for it.") + "\n</task>"
6857
+ };
6392
6858
  }
6393
6859
 
6394
6860
  // src/session-resume-store.ts
6395
- import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "fs";
6396
- import { homedir as homedir6 } from "os";
6397
- import { join as join8 } from "path";
6861
+ import { existsSync as existsSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync5, renameSync as renameSync4, writeFileSync as writeFileSync5 } from "fs";
6862
+ import { homedir as homedir7 } from "os";
6863
+ import { join as join9 } from "path";
6398
6864
  var MAX_RESUME_RECORDS = 256;
6399
6865
  var RESUME_RECORD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
6400
6866
  var records = null;
6401
- var pathOverride2 = null;
6867
+ var pathOverride3 = null;
6402
6868
  function resumeStorePath() {
6403
- if (pathOverride2) return pathOverride2;
6404
- const stateRoot = process.env.XDG_STATE_HOME || join8(homedir6(), ".local", "state");
6405
- return join8(stateRoot, "opencode-claude-code-plugin", "claude-sessions.json");
6869
+ if (pathOverride3) return pathOverride3;
6870
+ const stateRoot = process.env.XDG_STATE_HOME || join9(homedir7(), ".local", "state");
6871
+ return join9(stateRoot, "opencode-claude-code-plugin", "claude-sessions.json");
6406
6872
  }
6407
6873
  function isDigest(value) {
6408
6874
  const entry = value;
@@ -6416,7 +6882,7 @@ function load2() {
6416
6882
  if (records) return records;
6417
6883
  records = /* @__PURE__ */ new Map();
6418
6884
  try {
6419
- const parsed = JSON.parse(readFileSync4(resumeStorePath(), "utf8"));
6885
+ const parsed = JSON.parse(readFileSync5(resumeStorePath(), "utf8"));
6420
6886
  const entries = parsed && typeof parsed === "object" ? parsed.sessions : null;
6421
6887
  if (entries && typeof entries === "object") {
6422
6888
  const cutoff = Date.now() - RESUME_RECORD_MAX_AGE_MS;
@@ -6431,7 +6897,7 @@ function save2(update) {
6431
6897
  const file = resumeStorePath();
6432
6898
  const merged = /* @__PURE__ */ new Map();
6433
6899
  try {
6434
- const onDisk = JSON.parse(readFileSync4(file, "utf8"))?.sessions;
6900
+ const onDisk = JSON.parse(readFileSync5(file, "utf8"))?.sessions;
6435
6901
  if (onDisk && typeof onDisk === "object") {
6436
6902
  for (const [key, record] of Object.entries(onDisk)) {
6437
6903
  if (isRecord2(record)) merged.set(key, record);
@@ -6444,12 +6910,12 @@ function save2(update) {
6444
6910
  const kept = [...merged.entries()].filter(([, record]) => record.updatedAt >= cutoff).sort((a, b) => a[1].updatedAt - b[1].updatedAt).slice(-MAX_RESUME_RECORDS);
6445
6911
  records = new Map(kept);
6446
6912
  try {
6447
- mkdirSync4(join8(file, ".."), { recursive: true, mode: 448 });
6913
+ mkdirSync5(join9(file, ".."), { recursive: true, mode: 448 });
6448
6914
  const tmp = `${file}.${process.pid}.tmp`;
6449
- writeFileSync4(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
6915
+ writeFileSync5(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
6450
6916
  mode: 384
6451
6917
  });
6452
- renameSync3(tmp, file);
6918
+ renameSync4(tmp, file);
6453
6919
  } catch (err) {
6454
6920
  log.debug("could not write the claude session resume store", {
6455
6921
  error: err instanceof Error ? err.message : String(err)
@@ -6867,7 +7333,7 @@ async function requestSideQuestion(activeProcess, question, options) {
6867
7333
  }
6868
7334
  });
6869
7335
  pendingProcesses.add(proc);
6870
- return new Promise((resolve6, reject) => {
7336
+ return new Promise((resolve7, reject) => {
6871
7337
  const event = `side-question:${requestId}`;
6872
7338
  let settled = false;
6873
7339
  let sent = false;
@@ -6922,7 +7388,7 @@ async function requestSideQuestion(activeProcess, question, options) {
6922
7388
  }
6923
7389
  settled = true;
6924
7390
  cleanup();
6925
- resolve6({ response: result.response, synthetic: result.synthetic });
7391
+ resolve7({ response: result.response, synthetic: result.synthetic });
6926
7392
  };
6927
7393
  const timer = setTimeout(() => {
6928
7394
  fail(new Error(`/btw timed out after ${timeoutMs}ms.`), true);
@@ -7097,16 +7563,16 @@ function isTurnInFlight(ap) {
7097
7563
  }
7098
7564
  function awaitTurnIdle(ap, timeoutMs) {
7099
7565
  if (!ap.turnInFlight) return Promise.resolve(true);
7100
- return new Promise((resolve6) => {
7566
+ return new Promise((resolve7) => {
7101
7567
  const wake = () => {
7102
7568
  clearTimeout(timer);
7103
- resolve6(true);
7569
+ resolve7(true);
7104
7570
  };
7105
7571
  const timer = setTimeout(() => {
7106
7572
  const waiters = ap.turnIdleWaiters ?? [];
7107
7573
  const at = waiters.indexOf(wake);
7108
7574
  if (at >= 0) waiters.splice(at, 1);
7109
- resolve6(false);
7575
+ resolve7(false);
7110
7576
  }, timeoutMs);
7111
7577
  (ap.turnIdleWaiters ??= []).push(wake);
7112
7578
  });
@@ -7247,14 +7713,14 @@ function hasProcessExited(proc) {
7247
7713
  }
7248
7714
  function waitForProcessExit(proc, timeoutMs) {
7249
7715
  if (hasProcessExited(proc)) return Promise.resolve(true);
7250
- return new Promise((resolve6) => {
7716
+ return new Promise((resolve7) => {
7251
7717
  const onExit = () => {
7252
7718
  clearTimeout(timer);
7253
- resolve6(true);
7719
+ resolve7(true);
7254
7720
  };
7255
7721
  const timer = setTimeout(() => {
7256
7722
  proc.off("exit", onExit);
7257
- resolve6(hasProcessExited(proc));
7723
+ resolve7(hasProcessExited(proc));
7258
7724
  }, timeoutMs);
7259
7725
  proc.once("exit", onExit);
7260
7726
  });
@@ -7321,6 +7787,7 @@ function deleteClaudeSessionId(key) {
7321
7787
  if (claudeSessionId) clearLedger(claudeSessionId);
7322
7788
  claudeSessions.delete(key);
7323
7789
  forgetForkFingerprint(key);
7790
+ forgetServedConversation(key);
7324
7791
  forgetResumePoint(key);
7325
7792
  }
7326
7793
  function transferClaudeSession(fromKey, toKey) {
@@ -7332,6 +7799,7 @@ function transferClaudeSession(fromKey, toKey) {
7332
7799
  setClaudeSessionId(toKey, claudeSessionId);
7333
7800
  moveResumePoint(fromKey, toKey);
7334
7801
  moveForkFingerprint(fromKey, toKey);
7802
+ moveServedConversation(fromKey, toKey);
7335
7803
  clearExitPlanModeQuestions(fromKey);
7336
7804
  clearAccountFailoverQuestions(fromKey);
7337
7805
  clearSubagentDispatchQuestions(fromKey);
@@ -7557,7 +8025,9 @@ function buildCliArgs(opts) {
7557
8025
  thinking,
7558
8026
  thinkingDisplay,
7559
8027
  fastMode,
7560
- cliVersion
8028
+ cliVersion,
8029
+ maxTurns,
8030
+ maxBudgetUsd
7561
8031
  } = opts;
7562
8032
  const args = [
7563
8033
  "--print",
@@ -7628,6 +8098,8 @@ function buildCliArgs(opts) {
7628
8098
  if (fastMode && cliSupportsFastMode(cliVersion ?? null)) {
7629
8099
  args.push("--settings", JSON.stringify({ fastMode: true }));
7630
8100
  }
8101
+ if (maxTurns !== void 0) args.push("--max-turns", String(maxTurns));
8102
+ if (maxBudgetUsd !== void 0) args.push("--max-budget-usd", String(maxBudgetUsd));
7631
8103
  if (skipPermissions && permissionMode !== "plan" && !readOnly) {
7632
8104
  args.push("--dangerously-skip-permissions");
7633
8105
  }
@@ -7781,7 +8253,7 @@ async function waitForSessionIdle(client, sessionID, options = {}) {
7781
8253
  if (options.stop?.()) return true;
7782
8254
  if (await sessionStatus(client, sessionID) !== "busy") return true;
7783
8255
  if (Date.now() - started2 >= timeoutMs) return false;
7784
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
8256
+ await new Promise((resolve7) => setTimeout(resolve7, pollMs));
7785
8257
  }
7786
8258
  }
7787
8259
  async function deliverAsideInline(client, sessionID, text, options = {}) {
@@ -7792,7 +8264,7 @@ async function deliverAsideInline(client, sessionID, text, options = {}) {
7792
8264
  if (emitAsideInline(sessionID, text)) return true;
7793
8265
  if (Date.now() - started2 >= timeoutMs) return false;
7794
8266
  if (await sessionStatus(client, sessionID) !== "busy") return false;
7795
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
8267
+ await new Promise((resolve7) => setTimeout(resolve7, pollMs));
7796
8268
  }
7797
8269
  }
7798
8270
  async function waitForAsideProcess(client, sessionID, options = {}) {
@@ -7813,7 +8285,7 @@ async function waitForAsideProcess(client, sessionID, options = {}) {
7813
8285
  log.warn("btw: a turn is running but no claude process appeared for it", { sessionID, waitedMs });
7814
8286
  return void 0;
7815
8287
  }
7816
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
8288
+ await new Promise((resolve7) => setTimeout(resolve7, pollMs));
7817
8289
  }
7818
8290
  }
7819
8291
  async function settleSessionBusy(client, sessionID, active, options = {}) {
@@ -7825,7 +8297,7 @@ async function settleSessionBusy(client, sessionID, active, options = {}) {
7825
8297
  if (status === "busy") return true;
7826
8298
  if (status === "unknown") return isProcessBusy(active);
7827
8299
  if (Date.now() - started2 >= settleMs) return false;
7828
- await new Promise((resolve6) => setTimeout(resolve6, pollMs));
8300
+ await new Promise((resolve7) => setTimeout(resolve7, pollMs));
7829
8301
  }
7830
8302
  }
7831
8303
  function errorText(error) {
@@ -7872,10 +8344,10 @@ async function handleBtwCommand(client, input, options = {}) {
7872
8344
  let inlineDone = false;
7873
8345
  let markInlineDelivered = () => {
7874
8346
  };
7875
- const inlineDelivered = new Promise((resolve6) => {
8347
+ const inlineDelivered = new Promise((resolve7) => {
7876
8348
  markInlineDelivered = () => {
7877
8349
  inlineDone = true;
7878
- resolve6("inline");
8350
+ resolve7("inline");
7879
8351
  };
7880
8352
  });
7881
8353
  if (isSideQuestionPending(active)) {
@@ -7953,10 +8425,147 @@ async function handleBtwCommand(client, input, options = {}) {
7953
8425
  }
7954
8426
  }
7955
8427
 
8428
+ // src/budget-ledger.ts
8429
+ var BUDGET_MARKER = "\u258C **budget:**";
8430
+ var DEFAULT_BUDGET_WARN_AT = [50, 80];
8431
+ var MAX_SESSIONS = 256;
8432
+ var MAX_CLAUDE_SESSIONS = 512;
8433
+ var sessions2 = /* @__PURE__ */ new Map();
8434
+ var lastTotals = /* @__PURE__ */ new Map();
8435
+ function touch2(session) {
8436
+ let entry = sessions2.get(session);
8437
+ if (entry) {
8438
+ sessions2.delete(session);
8439
+ } else {
8440
+ entry = { own: 0, notedPercent: 0 };
8441
+ }
8442
+ sessions2.set(session, entry);
8443
+ while (sessions2.size > MAX_SESSIONS) {
8444
+ const oldest = sessions2.keys().next();
8445
+ if (oldest.done) break;
8446
+ sessions2.delete(oldest.value);
8447
+ }
8448
+ return entry;
8449
+ }
8450
+ function resolveBudgetUsd(value, option) {
8451
+ if (value === void 0 || value === null) return void 0;
8452
+ const parsed = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
8453
+ if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) return parsed;
8454
+ log.warn("budget option refused: not a positive number of dollars", { option });
8455
+ return void 0;
8456
+ }
8457
+ function resolvePositiveInteger(value, option) {
8458
+ if (value === void 0 || value === null) return void 0;
8459
+ const parsed = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
8460
+ if (typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0) return parsed;
8461
+ log.warn("cap option refused: not a positive whole number", { option });
8462
+ return void 0;
8463
+ }
8464
+ function resolveBudgetWarnAt(value) {
8465
+ if (value === void 0 || value === null) return [...DEFAULT_BUDGET_WARN_AT];
8466
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "number" && Number.isFinite(entry) && entry > 0 && entry < 100)) {
8467
+ log.warn("budgetWarnAt refused: expected percentages between 0 and 100", {
8468
+ option: "budgetWarnAt"
8469
+ });
8470
+ return [...DEFAULT_BUDGET_WARN_AT];
8471
+ }
8472
+ return [...new Set(value)].sort((a, b) => a - b);
8473
+ }
8474
+ function costDelta(claudeSessionId, totalCostUsd, cumulative) {
8475
+ if (!Number.isFinite(totalCostUsd) || totalCostUsd <= 0) return 0;
8476
+ if (!cumulative || !claudeSessionId) return totalCostUsd;
8477
+ const previous = lastTotals.get(claudeSessionId);
8478
+ lastTotals.delete(claudeSessionId);
8479
+ lastTotals.set(claudeSessionId, totalCostUsd);
8480
+ while (lastTotals.size > MAX_CLAUDE_SESSIONS) {
8481
+ const oldest = lastTotals.keys().next();
8482
+ if (oldest.done) break;
8483
+ lastTotals.delete(oldest.value);
8484
+ }
8485
+ if (previous === void 0 || totalCostUsd < previous) return totalCostUsd;
8486
+ return totalCostUsd - previous;
8487
+ }
8488
+ function recordSessionCost(session, deltaUsd) {
8489
+ if (!(deltaUsd > 0)) return;
8490
+ touch2(session).own += deltaUsd;
8491
+ }
8492
+ function sessionParentKnown(session) {
8493
+ return sessions2.get(session)?.parent !== void 0;
8494
+ }
8495
+ function noteSessionParent(session, parent) {
8496
+ touch2(session).parent = parent && parent !== session ? parent : null;
8497
+ }
8498
+ function isChildSession(session) {
8499
+ return typeof sessions2.get(session)?.parent === "string";
8500
+ }
8501
+ function budgetRoot(session) {
8502
+ let current = session;
8503
+ const seen = /* @__PURE__ */ new Set([current]);
8504
+ for (; ; ) {
8505
+ const parent = sessions2.get(current)?.parent;
8506
+ if (typeof parent !== "string" || seen.has(parent)) return current;
8507
+ seen.add(parent);
8508
+ current = parent;
8509
+ }
8510
+ }
8511
+ function sessionSpend(root) {
8512
+ let total = 0;
8513
+ for (const [session, entry] of sessions2) {
8514
+ if (entry.own > 0 && budgetRoot(session) === root) total += entry.own;
8515
+ }
8516
+ return total;
8517
+ }
8518
+ function takeCrossedThreshold(root, capUsd, warnAt) {
8519
+ const spentUsd = sessionSpend(root);
8520
+ const percentSpent = spentUsd / capUsd * 100;
8521
+ const entry = touch2(root);
8522
+ const crossed = [...warnAt, 100].filter(
8523
+ (threshold) => threshold <= percentSpent && threshold > entry.notedPercent
8524
+ );
8525
+ if (crossed.length === 0) return null;
8526
+ const percent = Math.max(...crossed);
8527
+ entry.notedPercent = percent;
8528
+ return { percent, spentUsd };
8529
+ }
8530
+ function formatUsd(value) {
8531
+ if (value >= 100) return `$${value.toFixed(0)}`;
8532
+ if (value >= 1) return `$${value.toFixed(2)}`;
8533
+ return `$${value.toFixed(4)}`;
8534
+ }
8535
+ function formatBudgetWarnNote(percent, spentUsd, capUsd) {
8536
+ if (percent >= 100) {
8537
+ return `${BUDGET_MARKER} this conversation has spent ${formatUsd(spentUsd)} of its ${formatUsd(capUsd)} \`maxBudgetUsd\`, so the next message will not start a turn. Raise \`maxBudgetUsd\` and relaunch opencode, or start a new session.`;
8538
+ }
8539
+ return `${BUDGET_MARKER} this conversation has spent ${formatUsd(spentUsd)}, ${percent}% of its ${formatUsd(capUsd)} \`maxBudgetUsd\`, counting the subagents it started.`;
8540
+ }
8541
+ function formatBudgetStopNote(spentUsd, capUsd) {
8542
+ return `${BUDGET_MARKER} this conversation has spent ${formatUsd(spentUsd)} against its ${formatUsd(capUsd)} \`maxBudgetUsd\`, so this turn was not started and nothing was sent to Claude. Raise \`maxBudgetUsd\` in the provider options and relaunch opencode (plugin options are read once at startup), or start a new session.`;
8543
+ }
8544
+ function recordResultCost(budget, claudeSessionId, totalCostUsd) {
8545
+ if (typeof totalCostUsd !== "number") return;
8546
+ recordSessionCost(budget.session, costDelta(claudeSessionId, totalCostUsd, budget.cumulative));
8547
+ }
8548
+ function takeBudgetNote(budget) {
8549
+ if (!budget.notes) return null;
8550
+ const crossed = takeCrossedThreshold(budgetRoot(budget.session), budget.capUsd, budget.warnAt);
8551
+ return crossed ? formatBudgetWarnNote(crossed.percent, crossed.spentUsd, budget.capUsd) : null;
8552
+ }
8553
+ function snapshotBudgets() {
8554
+ const roots = /* @__PURE__ */ new Map();
8555
+ for (const [session, entry] of sessions2) {
8556
+ const root = budgetRoot(session);
8557
+ const row = roots.get(root) ?? { session: root, spentUsd: 0, children: 0 };
8558
+ row.spentUsd += entry.own;
8559
+ if (root !== session && entry.own > 0) row.children++;
8560
+ roots.set(root, row);
8561
+ }
8562
+ return [...roots.values()].filter((row) => row.spentUsd > 0).sort((a, b) => b.spentUsd - a.spentUsd);
8563
+ }
8564
+
7956
8565
  // src/diagnostic-bundle.ts
7957
8566
  import { createHash as createHash4, randomBytes as randomBytes2 } from "crypto";
7958
8567
  import { openSync, readSync, closeSync, fstatSync, realpathSync } from "fs";
7959
- import { homedir as homedir7 } from "os";
8568
+ import { homedir as homedir8 } from "os";
7960
8569
 
7961
8570
  // src/log-messages.ts
7962
8571
  var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
@@ -7970,18 +8579,32 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
7970
8579
  "account failover declined; ending the turn",
7971
8580
  "accountGroups must be an object mapping an account name to a group name; ignoring it",
7972
8581
  "accountGroups names an account that is not configured; ignoring it",
8582
+ "agent cap not applied: this claude CLI does not take the flag",
7973
8583
  "agent effort override refused: unknown level",
8584
+ "agent maxBudgetUsd ignored: the CLI counts it over a reused process's whole life, so it applies to subagents only",
8585
+ "agent maxBudgetUsd refused: not a positive number of dollars",
8586
+ "agent maxTurns / maxBudgetUsd not applied: the interactive transport cannot pass print-only flags",
8587
+ "agent maxTurns refused: not a positive whole number",
7974
8588
  "agent model override refused: unknown model",
7975
8589
  "agent prompt cache ttl refused: unknown value",
8590
+ "an opencode read deny rule could not be mapped onto Claude's own Read; that path stays readable by the CLI's own file tools",
7976
8591
  "auto-continuation stopped",
7977
8592
  "auto-continuing incomplete claude result",
8593
+ "automatic account switch skipped an account whose runtime could not be prepared",
8594
+ "background dispatch refused by maxBackgroundSubagents",
7978
8595
  "btw: a turn is running but no claude process appeared for it",
7979
8596
  "btw: early aside failed; the message will ask again",
8597
+ "budget option refused: not a positive number of dollars",
8598
+ "budgetWarnAt refused: expected percentages between 0 and 100",
7980
8599
  "cannot interrupt this transport; waiting for the turn to end",
8600
+ "cap option refused: not a positive whole number",
7981
8601
  "carried this conversation's claude transcript to the other account",
7982
8602
  "carrying the claude conversation over to the new session key",
7983
8603
  "claude --version output unparseable",
8604
+ "claude account cannot serve this turn; continuing it on the next account automatically",
8605
+ "claude account is near its usage limit; new turns move to another account until the window resets",
7984
8606
  "claude account is out of usage; the turn ends with a note",
8607
+ "claude account usage crossed a warning threshold",
7985
8608
  "claude already registers a different skill under this name; not bridging ours",
7986
8609
  "claude cli < 2.1.142 detected; Opus 4.7 thinking summaries unavailable. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
7987
8610
  "claude cli does not support --plugin-dir; opencode skills will not be bridged. Run `npm i -g @anthropic-ai/claude-code` to upgrade.",
@@ -8004,6 +8627,8 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8004
8627
  "cleanup-stale: error processing cache root",
8005
8628
  "cleanup-stale: rmSync failed",
8006
8629
  "continuing this conversation's claude session under the new model or effort instead of replaying it",
8630
+ "conversation budget reached; turn not started",
8631
+ "conversation budget threshold crossed",
8007
8632
  "copied a shared Claude capability into the account config dir instead of linking it; later edits to the original will not be picked up",
8008
8633
  "could not share a Claude capability with the account",
8009
8634
  "delivering proxy results after interrupted continuation",
@@ -8054,6 +8679,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8054
8679
  "interactiveBypass is ignored: skipPermissions governs the interactive transport too",
8055
8680
  "malformed data URI, skipping file part",
8056
8681
  "no active process to respawn (start watchdog); ending turn",
8682
+ "no configured account could take this turn; it ends here",
8057
8683
  "no proxyTools entry was recognised; nothing will be proxied this turn",
8058
8684
  "no stdout after envelope write; respawning claude process to resume conversation",
8059
8685
  "opencode 2 event subscription ended; deleted sessions keep their claude process until idle eviction",
@@ -8069,6 +8695,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8069
8695
  "proxy call past its deadline, but opencode is still serving it; waiting",
8070
8696
  "proxy call still waiting, deadline approaching",
8071
8697
  "proxy call still waiting, no deadline",
8698
+ "proxy-mcp admission check failed; letting the call through",
8072
8699
  "proxy-mcp client closed a tool call before its result",
8073
8700
  "proxy-mcp dropping result for a closed tool call",
8074
8701
  "proxy-mcp interceptor failed",
@@ -8083,6 +8710,8 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
8083
8710
  "remote URL images are not supported by Claude CLI, skipping",
8084
8711
  "replaying stdout the child emitted between turns",
8085
8712
  "replaying the conversation as text: no claude session was available to continue",
8713
+ "replaying the conversation as text: the thread was edited or undone",
8714
+ "replaying the messages this claude session missed: the conversation continued under another session key",
8086
8715
  "replaying this conversation as text on the other account",
8087
8716
  "replaying this conversation rather than carrying it across accounts",
8088
8717
  "result fallback timer fired \u2014 closing stream without result event",
@@ -8153,6 +8782,20 @@ var BUNDLE_DATA_ALLOWLIST = {
8153
8782
  isError: "flag",
8154
8783
  planModeQuestion: "flag",
8155
8784
  subagentDispatchCrossGroup: "flag",
8785
+ // Whether opencode's read deny rules are carried onto Claude's own Read,
8786
+ // and how many were. A count and a flag only: the patterns themselves are
8787
+ // the operator's config text and are never logged (h #g243).
8788
+ mapReadRules: "flag",
8789
+ readRuleDenies: "count",
8790
+ // Budgets and caps (h #g245): numbers and option names only, never spend
8791
+ // per conversation beyond what the doctor table already prints.
8792
+ child: "flag",
8793
+ countUnreadable: "flag",
8794
+ cap: "count",
8795
+ requested: "count",
8796
+ running: "count",
8797
+ option: "name",
8798
+ cliFlag: "name",
8156
8799
  kept: "flag",
8157
8800
  listsAgentTypes: "flag",
8158
8801
  opencodeHasQuestion: "flag",
@@ -8160,6 +8803,8 @@ var BUNDLE_DATA_ALLOWLIST = {
8160
8803
  sse: "flag",
8161
8804
  strictMcpConfig: "flag",
8162
8805
  supported: "flag",
8806
+ // Whether a usage threshold moved new turns to another account (h #g244).
8807
+ switched: "flag",
8163
8808
  turnStats: "flag",
8164
8809
  // counts and sizes: a length is a shape, never content
8165
8810
  attempts: "count",
@@ -8185,12 +8830,22 @@ var BUNDLE_DATA_ALLOWLIST = {
8185
8830
  preTokens: "count",
8186
8831
  released: "count",
8187
8832
  removed: "count",
8833
+ // The three counts of a rewound thread: how much of what this key's Claude
8834
+ // session was answering is still in the conversation, and how much of it is
8835
+ // not (h #g242).
8836
+ sharedMessages: "count",
8837
+ removedMessages: "count",
8838
+ removedUserMessages: "count",
8188
8839
  // The three counts of a released subagent dispatch: how many subagents, how
8189
8840
  // many of them carried a choice, and which task a refused answer was for.
8190
8841
  claims: "count",
8191
8842
  tasks: "count",
8192
8843
  taskIndex: "count",
8193
8844
  skills: "count",
8845
+ // A usage reading crossing a warning threshold (h #g244): the percent used
8846
+ // and the thresholds it crossed, numbers only.
8847
+ utilization: "count",
8848
+ crossed: "count",
8194
8849
  stderrBytes: "count",
8195
8850
  textLength: "count",
8196
8851
  tools: "count",
@@ -8349,7 +9004,7 @@ function createIdHasher(salt = randomBytes2(16).toString("hex")) {
8349
9004
  return (raw) => createHash4("sha256").update(salt).update("\0").update(raw).digest("hex").slice(0, 8);
8350
9005
  }
8351
9006
  function createRedactionContext(options = {}) {
8352
- const home = options.home ?? homedir7();
9007
+ const home = options.home ?? homedir8();
8353
9008
  const homes = /* @__PURE__ */ new Set();
8354
9009
  if (home) homes.add(home);
8355
9010
  if (home) {
@@ -8643,6 +9298,202 @@ function buildLogBundleSection(input) {
8643
9298
  return lines.join("\n");
8644
9299
  }
8645
9300
 
9301
+ // src/read-rules.ts
9302
+ import * as path8 from "path";
9303
+ import * as os4 from "os";
9304
+ var OPENCODE_DEFAULT_READ_RULES = [
9305
+ { pattern: "*", effect: "allow", source: "default" },
9306
+ { pattern: "*.env", effect: "ask", source: "default" },
9307
+ { pattern: "*.env.*", effect: "ask", source: "default" },
9308
+ { pattern: "*.env.example", effect: "allow", source: "default" }
9309
+ ];
9310
+ var READ_PERMISSION = "read";
9311
+ function wildcardMatch(input, pattern) {
9312
+ const normalized = input.replaceAll("\\", "/");
9313
+ let escaped = pattern.replaceAll("\\", "/").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
9314
+ if (escaped.endsWith(" .*")) escaped = `${escaped.slice(0, -3)}( .*)?`;
9315
+ return new RegExp(`^${escaped}$`, process.platform === "win32" ? "si" : "s").test(normalized);
9316
+ }
9317
+ function expandHome2(pattern, home) {
9318
+ if (pattern === "~" || pattern === "$HOME") return home;
9319
+ if (pattern.startsWith("~/")) return home + pattern.slice(1);
9320
+ if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) return home + pattern.slice(5);
9321
+ return pattern;
9322
+ }
9323
+ function isPlainObject2(value) {
9324
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9325
+ }
9326
+ function isEffect(value) {
9327
+ return value === "allow" || value === "ask" || value === "deny";
9328
+ }
9329
+ function mergePermissionBlock(base, incoming) {
9330
+ const out = { ...base };
9331
+ for (const [key, value] of Object.entries(incoming)) {
9332
+ const existing = out[key];
9333
+ out[key] = isPlainObject2(existing) && isPlainObject2(value) ? mergePermissionBlock(existing, value) : value;
9334
+ }
9335
+ return out;
9336
+ }
9337
+ function v1PermissionBlock(layers) {
9338
+ let permission = {};
9339
+ let tools = {};
9340
+ for (const layer of layers) {
9341
+ const block = layer.permission;
9342
+ if (typeof block === "string") permission = mergePermissionBlock(permission, { "*": block });
9343
+ else if (isPlainObject2(block)) permission = mergePermissionBlock(permission, block);
9344
+ if (isPlainObject2(layer.tools)) tools = { ...tools, ...layer.tools };
9345
+ }
9346
+ const fromEnv = process.env.OPENCODE_PERMISSION;
9347
+ if (fromEnv) {
9348
+ try {
9349
+ const parsed = JSON.parse(fromEnv);
9350
+ if (isPlainObject2(parsed)) permission = mergePermissionBlock(permission, parsed);
9351
+ } catch {
9352
+ }
9353
+ }
9354
+ const derived = {};
9355
+ if (tools.read === false) derived[READ_PERMISSION] = "deny";
9356
+ else if (tools.read === true) derived[READ_PERMISSION] = "allow";
9357
+ return mergePermissionBlock(derived, permission);
9358
+ }
9359
+ function rulesFromV1Block(block, home) {
9360
+ const rules = [];
9361
+ for (const [key, value] of Object.entries(block)) {
9362
+ if (!wildcardMatch(READ_PERMISSION, key)) continue;
9363
+ if (isEffect(value)) {
9364
+ rules.push({ pattern: "*", effect: value, source: "config" });
9365
+ continue;
9366
+ }
9367
+ if (!isPlainObject2(value)) continue;
9368
+ for (const [pattern, effect] of Object.entries(value)) {
9369
+ if (!isEffect(effect)) continue;
9370
+ rules.push({ pattern: expandHome2(String(pattern), home), effect, source: "config" });
9371
+ }
9372
+ }
9373
+ return rules;
9374
+ }
9375
+ function rulesFromV2Array(value, home) {
9376
+ if (!Array.isArray(value)) return [];
9377
+ const rules = [];
9378
+ for (const entry of value) {
9379
+ if (!isPlainObject2(entry)) continue;
9380
+ const { action, resource, effect } = entry;
9381
+ if (typeof action !== "string" || !wildcardMatch(READ_PERMISSION, action)) continue;
9382
+ if (!isEffect(effect)) continue;
9383
+ const pattern = typeof resource === "string" && resource.length > 0 ? resource : "*";
9384
+ rules.push({ pattern: expandHome2(pattern, home), effect, source: "config" });
9385
+ }
9386
+ return rules;
9387
+ }
9388
+ function resolveReadRules(layers, hostApi = "v1", options = {}) {
9389
+ const home = options.home ?? os4.homedir();
9390
+ const rules = options.includeDefaults === false ? [] : [...OPENCODE_DEFAULT_READ_RULES];
9391
+ if (hostApi === "v2") {
9392
+ for (const layer of layers) {
9393
+ const block = typeof layer.permission === "string" ? { "*": layer.permission } : isPlainObject2(layer.permission) ? layer.permission : null;
9394
+ if (block) rules.push(...rulesFromV1Block(block, home));
9395
+ rules.push(...rulesFromV2Array(layer.permissions, home));
9396
+ }
9397
+ return rules;
9398
+ }
9399
+ rules.push(...rulesFromV1Block(v1PermissionBlock(layers), home));
9400
+ for (const layer of layers) rules.push(...rulesFromV2Array(layer.permissions, home));
9401
+ return rules;
9402
+ }
9403
+ function describeReadPatternRefusal(reason) {
9404
+ switch (reason) {
9405
+ case "empty-pattern":
9406
+ return "the pattern is empty";
9407
+ case "contains-dotdot":
9408
+ return "it contains `..`, which leaves the worktree the two matchers agree on";
9409
+ }
9410
+ }
9411
+ function translateReadPattern(pattern, options = {}) {
9412
+ const raw = pattern.replaceAll("\\", "/").trim();
9413
+ if (raw.length === 0) return { refused: "empty-pattern" };
9414
+ if (raw.split("/").includes("..")) return { refused: "contains-dotdot" };
9415
+ if (/^\*+$/.test(raw)) return { rule: "**" };
9416
+ const widenSubtree = (value) => value.endsWith("/*") ? `${value}*` : value;
9417
+ if (path8.posix.isAbsolute(raw)) {
9418
+ return { rule: `/${widenSubtree(raw)}` };
9419
+ }
9420
+ if (!raw.includes("/")) return { rule: raw };
9421
+ const { worktree, cwd } = options;
9422
+ if (worktree && cwd && path8.resolve(worktree) !== path8.resolve(cwd)) {
9423
+ const anchored = path8.posix.join(path8.resolve(worktree).replaceAll("\\", "/"), widenSubtree(raw));
9424
+ return { rule: `/${anchored}` };
9425
+ }
9426
+ return { rule: widenSubtree(raw) };
9427
+ }
9428
+ function emptyMapping(enabled) {
9429
+ return { enabled, deny: [], ask: [], unmapped: [], shadowedAllows: [] };
9430
+ }
9431
+ function mapReadRules(rules, options = {}) {
9432
+ const deny = [];
9433
+ const ask = [];
9434
+ const unmapped = [];
9435
+ const shadowedAllows = [];
9436
+ const seen = /* @__PURE__ */ new Set();
9437
+ const supersededBy = (index, pattern, by) => rules.slice(index + 1).some((later) => by(later) && wildcardMatch(pattern, later.pattern));
9438
+ rules.forEach((rule, index) => {
9439
+ if (rule.effect === "ask") {
9440
+ if (supersededBy(index, rule.pattern, () => true)) return;
9441
+ if (!ask.includes(rule.pattern)) ask.push(rule.pattern);
9442
+ return;
9443
+ }
9444
+ if (rule.effect !== "deny") return;
9445
+ if (supersededBy(index, rule.pattern, (later) => later.effect !== "deny")) return;
9446
+ const translated = translateReadPattern(rule.pattern, options);
9447
+ if ("refused" in translated) {
9448
+ unmapped.push({ pattern: rule.pattern, reason: translated.refused });
9449
+ return;
9450
+ }
9451
+ const entry = `Read(${translated.rule})`;
9452
+ if (!seen.has(entry)) {
9453
+ seen.add(entry);
9454
+ deny.push(entry);
9455
+ }
9456
+ for (const later of rules.slice(index + 1)) {
9457
+ if (later.effect !== "allow") continue;
9458
+ if (!wildcardMatch(later.pattern, rule.pattern)) continue;
9459
+ if (!shadowedAllows.includes(later.pattern)) shadowedAllows.push(later.pattern);
9460
+ }
9461
+ });
9462
+ return { deny, ask, unmapped, shadowedAllows };
9463
+ }
9464
+ function resolveReadRuleMapping(input) {
9465
+ if (input.enabled === false) return emptyMapping(false);
9466
+ try {
9467
+ const rules = resolveReadRules(input.layers, input.hostApi ?? "v1", { home: input.home });
9468
+ const mapped = mapReadRules(rules, { worktree: input.worktree, cwd: input.cwd });
9469
+ for (const refused of mapped.unmapped) {
9470
+ log.warn(
9471
+ "an opencode read deny rule could not be mapped onto Claude's own Read; that path stays readable by the CLI's own file tools",
9472
+ { reason: refused.reason }
9473
+ );
9474
+ }
9475
+ if (mapped.deny.length > 0) {
9476
+ log.info("mapped opencode read deny rules onto the claude spawn", {
9477
+ rules: mapped.deny,
9478
+ askNotEnforced: mapped.ask.length
9479
+ });
9480
+ }
9481
+ return { enabled: true, ...mapped };
9482
+ } catch (err) {
9483
+ log.debug("could not resolve opencode read rules", {
9484
+ error: err instanceof Error ? err.message : String(err)
9485
+ });
9486
+ return emptyMapping(true);
9487
+ }
9488
+ }
9489
+ var lastReadRuleMapping;
9490
+ function recordReadRuleMapping(mapping) {
9491
+ lastReadRuleMapping = mapping;
9492
+ }
9493
+ function snapshotReadRuleMapping() {
9494
+ return lastReadRuleMapping;
9495
+ }
9496
+
8646
9497
  // src/plan-usage.ts
8647
9498
  import { execFile as execFile2 } from "child_process";
8648
9499
  var PLAN_USAGE_ARGUMENTS = /* @__PURE__ */ new Set(["usage", "cost", "stats", "limits"]);
@@ -8686,7 +9537,7 @@ function parsePlanUsage(stdout) {
8686
9537
  }
8687
9538
  function runCli(cliPath, args, timeoutMs, env) {
8688
9539
  const plan = planClaudeSpawn(cliPath, args);
8689
- return new Promise((resolve6, reject) => {
9540
+ return new Promise((resolve7, reject) => {
8690
9541
  execFile2(
8691
9542
  plan.file,
8692
9543
  plan.args,
@@ -8700,9 +9551,9 @@ function runCli(cliPath, args, timeoutMs, env) {
8700
9551
  windowsVerbatimArguments: plan.windowsVerbatimArguments
8701
9552
  },
8702
9553
  (error, stdout) => {
8703
- if (stdout && stdout.trim()) return resolve6(stdout);
9554
+ if (stdout && stdout.trim()) return resolve7(stdout);
8704
9555
  if (error) return reject(error);
8705
- resolve6(stdout ?? "");
9556
+ resolve7(stdout ?? "");
8706
9557
  }
8707
9558
  );
8708
9559
  });
@@ -8741,13 +9592,13 @@ async function fetchPlanUsage(cliPath, options = {}) {
8741
9592
 
8742
9593
  // src/stale-build.ts
8743
9594
  import * as fs7 from "fs";
8744
- import * as path9 from "path";
9595
+ import * as path10 from "path";
8745
9596
  import { fileURLToPath as fileURLToPath2 } from "url";
8746
9597
 
8747
9598
  // src/startup-diagnostics.ts
8748
9599
  import { execFile as execFile3 } from "child_process";
8749
9600
  import * as fs6 from "fs";
8750
- import * as path8 from "path";
9601
+ import * as path9 from "path";
8751
9602
  import { promisify as promisify2 } from "util";
8752
9603
  import { fileURLToPath } from "url";
8753
9604
 
@@ -8782,8 +9633,8 @@ var cachedPluginVersion;
8782
9633
  function pluginVersion() {
8783
9634
  if (cachedPluginVersion) return cachedPluginVersion;
8784
9635
  try {
8785
- const here = path8.dirname(fileURLToPath(import.meta.url));
8786
- const raw = fs6.readFileSync(path8.join(here, "..", "package.json"), "utf8");
9636
+ const here = path9.dirname(fileURLToPath(import.meta.url));
9637
+ const raw = fs6.readFileSync(path9.join(here, "..", "package.json"), "utf8");
8787
9638
  const version = JSON.parse(raw).version;
8788
9639
  cachedPluginVersion = typeof version === "string" ? version : "unknown";
8789
9640
  } catch {
@@ -8807,7 +9658,7 @@ var opencodeVersionProbe;
8807
9658
  function detectOpencodeVersion(execPath = process.execPath) {
8808
9659
  if (opencodeVersionProbe) return opencodeVersionProbe;
8809
9660
  opencodeVersionProbe = (async () => {
8810
- if (!path8.basename(execPath).toLowerCase().includes("opencode")) {
9661
+ if (!path9.basename(execPath).toLowerCase().includes("opencode")) {
8811
9662
  log.debug("skipping opencode version probe: execPath is not opencode", { execPath });
8812
9663
  return void 0;
8813
9664
  }
@@ -8860,6 +9711,23 @@ function collectStartupDiagnostics(providers, opencodeVersion) {
8860
9711
  error: err instanceof Error ? err.message : String(err)
8861
9712
  });
8862
9713
  }
9714
+ const mapReadRules2 = firstOption(providers, "mapReadRules") !== false;
9715
+ let readRuleDenies = 0;
9716
+ if (mapReadRules2) {
9717
+ try {
9718
+ const hostApi = firstOption(providers, "hostApi") === "v2" ? "v2" : "v1";
9719
+ readRuleDenies = resolveReadRuleMapping({
9720
+ layers: opencodeConfigLayers(cwd.resolved, hostApi),
9721
+ hostApi,
9722
+ worktree: detectWorktree(cwd.resolved),
9723
+ cwd: cwd.resolved
9724
+ }).deny.length;
9725
+ } catch (err) {
9726
+ log.debug("startup diagnostics could not resolve opencode read rules", {
9727
+ error: err instanceof Error ? err.message : String(err)
9728
+ });
9729
+ }
9730
+ }
8863
9731
  let transport;
8864
9732
  try {
8865
9733
  transport = requestedTransport({
@@ -8895,6 +9763,8 @@ function collectStartupDiagnostics(providers, opencodeVersion) {
8895
9763
  planModeQuestion: firstOption(providers, "planModeQuestion") === true,
8896
9764
  subagentDispatch: firstOption(providers, "subagentDispatch") === "ask" ? "ask" : "off",
8897
9765
  subagentDispatchCrossGroup: firstOption(providers, "subagentDispatchCrossGroup") === true,
9766
+ mapReadRules: mapReadRules2,
9767
+ readRuleDenies,
8898
9768
  anthropicApiKeyInEnv: Boolean(
8899
9769
  process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN
8900
9770
  )
@@ -8988,7 +9858,7 @@ function createStaleBuildWatch(io) {
8988
9858
  const claimed = /* @__PURE__ */ new Set();
8989
9859
  const warned = /* @__PURE__ */ new Set();
8990
9860
  let checkedAt;
8991
- let cached = null;
9861
+ let cached2 = null;
8992
9862
  const read = () => {
8993
9863
  let onDisk;
8994
9864
  try {
@@ -9006,10 +9876,10 @@ function createStaleBuildWatch(io) {
9006
9876
  },
9007
9877
  check() {
9008
9878
  const now = io.now();
9009
- if (checkedAt !== void 0 && now - checkedAt < throttleMs) return cached;
9879
+ if (checkedAt !== void 0 && now - checkedAt < throttleMs) return cached2;
9010
9880
  checkedAt = now;
9011
9881
  const status = read();
9012
- cached = status.stale;
9882
+ cached2 = status.stale;
9013
9883
  if (status.stale && status.onDisk) {
9014
9884
  const identity = `${status.stale.kind}:${status.onDisk.version}:${status.onDisk.mtimeMs}:${status.onDisk.size}`;
9015
9885
  if (!warned.has(identity)) {
@@ -9022,7 +9892,7 @@ function createStaleBuildWatch(io) {
9022
9892
  });
9023
9893
  }
9024
9894
  }
9025
- return cached;
9895
+ return cached2;
9026
9896
  },
9027
9897
  describe() {
9028
9898
  return read();
@@ -9047,7 +9917,7 @@ function defaultEntryPath() {
9047
9917
  }
9048
9918
  function readOnDiskBuild(entryPath) {
9049
9919
  try {
9050
- const raw = fs7.readFileSync(path9.join(path9.dirname(entryPath), "..", "package.json"), "utf8");
9920
+ const raw = fs7.readFileSync(path10.join(path10.dirname(entryPath), "..", "package.json"), "utf8");
9051
9921
  const version = JSON.parse(raw).version;
9052
9922
  if (typeof version !== "string" || version.length === 0) return void 0;
9053
9923
  const stat = fs7.statSync(entryPath);
@@ -9272,10 +10142,24 @@ function formatDoctorReport(report) {
9272
10142
  );
9273
10143
  }
9274
10144
  lines.push("");
10145
+ lines.push("**opencode read rules on Claude's own Read**");
10146
+ lines.push("");
10147
+ lines.push(...formatReadRules(report.readRules));
10148
+ if (report.accountUsage) {
10149
+ lines.push("");
10150
+ lines.push("**Account usage**");
10151
+ lines.push("");
10152
+ lines.push(...formatAccountUsage(report.accountUsage));
10153
+ }
10154
+ lines.push("");
9275
10155
  lines.push("**Background subagents**");
9276
10156
  lines.push("");
9277
10157
  lines.push(...formatBackgroundSubagents(report.backgroundSubagents));
9278
10158
  lines.push("");
10159
+ lines.push("**Budgets and caps**");
10160
+ lines.push("");
10161
+ lines.push(...formatBudgets(report.budgets));
10162
+ lines.push("");
9279
10163
  lines.push("**Plan usage**");
9280
10164
  lines.push("");
9281
10165
  switch (report.planUsage.status) {
@@ -9306,7 +10190,82 @@ function formatDoctorReport(report) {
9306
10190
  lines.push("```");
9307
10191
  }
9308
10192
  }
9309
- return lines.join("\n");
10193
+ return lines.join("\n");
10194
+ }
10195
+ function formatReadRules(mapping) {
10196
+ const lines = [];
10197
+ if (!mapping) {
10198
+ lines.push(
10199
+ "Not mapped yet: no `claude` spawn has been planned in this process. The next message in a Claude Code session resolves them."
10200
+ );
10201
+ return lines;
10202
+ }
10203
+ if (!mapping.enabled) {
10204
+ lines.push(
10205
+ "Off (`mapReadRules: false`). opencode's `read` rules reach its own tools only, so a file opencode would refuse is still readable by the Read the CLI runs itself."
10206
+ );
10207
+ return lines;
10208
+ }
10209
+ if (mapping.deny.length === 0) {
10210
+ lines.push(
10211
+ "No `deny` rule to map. opencode's defaults only `ask` for `.env` files, and an ask cannot be carried onto a spawn that has nobody to ask (see below)."
10212
+ );
10213
+ } else {
10214
+ lines.push(`Denied on Claude's own Read (${mapping.deny.length}):`);
10215
+ lines.push("");
10216
+ for (const rule of mapping.deny) lines.push(`- \`${rule}\``);
10217
+ }
10218
+ if (mapping.ask.length > 0) {
10219
+ lines.push("");
10220
+ lines.push(`Not enforced, because they are \`ask\` rules (${mapping.ask.length}):`);
10221
+ lines.push("");
10222
+ for (const pattern of mapping.ask) lines.push(`- \`${pattern}\``);
10223
+ lines.push("");
10224
+ lines.push(
10225
+ "A headless spawn runs with the skip flag and there is no one for the CLI to ask, so an `ask` is reported rather than silently promoted to a deny. To close one, make it `deny` in opencode's `permission.read`, or add `Read` to `proxyTools` so the read runs in opencode and its own prompt applies."
10226
+ );
10227
+ }
10228
+ if (mapping.unmapped.length > 0) {
10229
+ lines.push("");
10230
+ lines.push(`Deny rules that could NOT be mapped (${mapping.unmapped.length}):`);
10231
+ lines.push("");
10232
+ for (const refused of mapping.unmapped) {
10233
+ lines.push(`- \`${refused.pattern}\`: ${describeReadPatternRefusal(refused.reason)}`);
10234
+ }
10235
+ }
10236
+ if (mapping.shadowedAllows.length > 0) {
10237
+ lines.push("");
10238
+ lines.push(`Allow rules a mapped deny now shadows (${mapping.shadowedAllows.length}):`);
10239
+ lines.push("");
10240
+ for (const pattern of mapping.shadowedAllows) lines.push(`- \`${pattern}\``);
10241
+ lines.push("");
10242
+ lines.push(
10243
+ "Claude Code's deny beats its allow, so an exception opencode would carve out of a deny cannot be expressed: Claude refuses these where opencode would allow them."
10244
+ );
10245
+ }
10246
+ return lines;
10247
+ }
10248
+ function formatAccountUsage(usage, now = Date.now()) {
10249
+ const rows = [];
10250
+ for (const account of [...usage.keys()].sort()) {
10251
+ for (const [window, entry] of usage.get(account)) {
10252
+ const used = entry.status === "rejected" && entry.utilization === null ? "out" : entry.utilization === null ? "unknown" : `${usagePercent(entry.utilization)}%`;
10253
+ const resets = entry.resetsAt !== void 0 ? formatResetsAtLocal(entry.resetsAt) ?? "unknown" : entry.resetsText ? tableCell(entry.resetsText) : "unknown";
10254
+ rows.push(
10255
+ `| ${account} | ${window} | ${used} | ${entry.status} | ${resets}${isReadingCurrent(entry, now) ? "" : " (window over)"} | ${formatAge(Math.max(0, now - entry.observedAt))} ago | ${entry.noted.length > 0 ? entry.noted.map((t) => `${t}%`).join(", ") : "none"} |`
10256
+ );
10257
+ }
10258
+ }
10259
+ if (rows.length === 0) {
10260
+ return [
10261
+ "No reading yet. Claude Code reports an account's usage on the stream when its view of the limits changes, usually on the first turn of a process; the interactive transport reports nothing until a limit."
10262
+ ];
10263
+ }
10264
+ return [
10265
+ "| account | window | used | status | resets | observed | noted at |",
10266
+ "|---|---|---|---|---|---|---|",
10267
+ ...rows
10268
+ ];
9310
10269
  }
9311
10270
  function formatBackgroundSubagents(state) {
9312
10271
  const lines = [];
@@ -9314,6 +10273,10 @@ function formatBackgroundSubagents(state) {
9314
10273
  lines.push(...formatRunningCount(state.running));
9315
10274
  lines.push("");
9316
10275
  }
10276
+ lines.push(
10277
+ state.cap === void 0 ? "maxBackgroundSubagents: off (no cap)" : `maxBackgroundSubagents: ${state.cap} at once; a background dispatch over it is refused whole`
10278
+ );
10279
+ lines.push("");
9317
10280
  const gate = state.gate;
9318
10281
  if (!gate) {
9319
10282
  lines.push(
@@ -9372,6 +10335,39 @@ function formatRunningCount(count) {
9372
10335
  }
9373
10336
  return lines;
9374
10337
  }
10338
+ function formatBudgets(budgets) {
10339
+ const lines = [];
10340
+ lines.push("| Field | Value |");
10341
+ lines.push("|---|---|");
10342
+ lines.push(
10343
+ `| maxBudgetUsd | ${budgets.maxBudgetUsd === void 0 ? "off (no cap)" : formatUsd(budgets.maxBudgetUsd)} |`
10344
+ );
10345
+ if (budgets.maxBudgetUsd !== void 0) {
10346
+ const warnAt = budgets.warnAt.map((percent) => `${percent}%`).join(", ");
10347
+ lines.push(`| budgetWarnAt | ${warnAt || "none"} |`);
10348
+ }
10349
+ lines.push(`| defaultSubagentMaxTurns | ${budgets.subagentDefaults.maxTurns ?? "unset"} |`);
10350
+ lines.push(
10351
+ `| defaultSubagentMaxBudgetUsd | ${budgets.subagentDefaults.maxBudgetUsd === void 0 ? "unset" : formatUsd(budgets.subagentDefaults.maxBudgetUsd)} |`
10352
+ );
10353
+ if (budgets.maxBudgetUsd === void 0) return lines;
10354
+ lines.push("");
10355
+ if (budgets.sessions.length === 0) {
10356
+ lines.push("No spend recorded by this process yet.");
10357
+ } else {
10358
+ lines.push("| session | spent | of cap | subagents counted |");
10359
+ lines.push("|---|---|---|---|");
10360
+ for (const row of budgets.sessions) {
10361
+ const percent = Math.round(row.spentUsd / budgets.maxBudgetUsd * 100);
10362
+ lines.push(`| ${row.session} | ${formatUsd(row.spentUsd)} | ${percent}% | ${row.children} |`);
10363
+ }
10364
+ }
10365
+ lines.push("");
10366
+ lines.push(
10367
+ "In memory only: an opencode restart starts every conversation's ledger again (a resumed Claude session's first result restores its own earlier total). A conversation at its cap ends each new turn before anything is spawned."
10368
+ );
10369
+ return lines;
10370
+ }
9375
10371
  function describeBackgroundGateSource(gate) {
9376
10372
  if (gate.hostApi === "v2") {
9377
10373
  return "opencode 2 offers `background` unconditionally, so the registry is not consulted";
@@ -9450,14 +10446,23 @@ async function gatherDoctorReport(options) {
9450
10446
  processes,
9451
10447
  pendingCalls: snapshotPendingProxyCalls(),
9452
10448
  proxyServers,
10449
+ readRules: snapshotReadRuleMapping(),
9453
10450
  mcpServerErrors: snapshotMcpServerErrors(),
9454
10451
  pluginLoadFailures: snapshotPluginLoadFailures(),
9455
10452
  hookFailures: snapshotHookFailures(),
9456
10453
  planUsage,
10454
+ accountUsage: snapshotAccountUsage(),
9457
10455
  backgroundSubagents: {
9458
10456
  gate: snapshotBackgroundSubagentGate(),
9459
10457
  ledgers: snapshotBackgroundTasks(),
9460
- running: await countRunningBackgroundTasks().catch(() => void 0)
10458
+ running: await countRunningBackgroundTasks().catch(() => void 0),
10459
+ cap: options.maxBackgroundSubagents
10460
+ },
10461
+ budgets: {
10462
+ maxBudgetUsd: options.maxBudgetUsd,
10463
+ warnAt: options.budgetWarnAt ?? [],
10464
+ subagentDefaults: getDefaultSubagentCliCaps(),
10465
+ sessions: snapshotBudgets()
9461
10466
  }
9462
10467
  };
9463
10468
  }
@@ -9490,7 +10495,32 @@ function withholdFreeText(report) {
9490
10495
  hookFailures: report.hookFailures.map((failure) => ({
9491
10496
  ...failure,
9492
10497
  stderr: withhold(failure.stderr)
9493
- }))
10498
+ })),
10499
+ // The reset phrase the TUI printed is Claude Code's text, not the plugin's.
10500
+ accountUsage: report.accountUsage && new Map(
10501
+ [...report.accountUsage].map(([account, windows]) => [
10502
+ account,
10503
+ new Map(
10504
+ [...windows].map(([window, entry]) => [
10505
+ window,
10506
+ entry.resetsText ? { ...entry, resetsText: withhold(entry.resetsText) } : entry
10507
+ ])
10508
+ )
10509
+ ])
10510
+ ),
10511
+ // The operator's own glob patterns. Not plugin-authored, so they follow
10512
+ // the same rule as a hook's stderr: the shape and the counts survive a
10513
+ // bundle, the text does not.
10514
+ readRules: report.readRules && {
10515
+ ...report.readRules,
10516
+ deny: report.readRules.deny.map(withhold),
10517
+ ask: report.readRules.ask.map(withhold),
10518
+ unmapped: report.readRules.unmapped.map((refused) => ({
10519
+ ...refused,
10520
+ pattern: withhold(refused.pattern)
10521
+ })),
10522
+ shadowedAllows: report.readRules.shadowedAllows.map(withhold)
10523
+ }
9494
10524
  };
9495
10525
  }
9496
10526
  function renderDoctorReport(report, options) {
@@ -9687,11 +10717,14 @@ var PLUGIN_NOTE_MARKERS = [
9687
10717
  FAILOVER_MARKER,
9688
10718
  ACCOUNT_BLOCK_MARKER,
9689
10719
  ACCOUNT_GROUP_MARKER,
10720
+ CONVERSATION_REWOUND_MARKER,
9690
10721
  USAGE_LIMIT_MARKER,
10722
+ USAGE_MARKER,
9691
10723
  MODEL_FALLBACK_MARKER,
9692
10724
  UNATTENDED_REPLAY_MARKER,
9693
10725
  STALE_BUILD_MARKER,
9694
- SUBAGENT_DISPATCH_MARKER
10726
+ SUBAGENT_DISPATCH_MARKER,
10727
+ BUDGET_MARKER
9695
10728
  ];
9696
10729
  function isPluginNote(part) {
9697
10730
  if (!part || part.type !== "text" || typeof part.text !== "string") return false;
@@ -9837,8 +10870,10 @@ function clipWithMarker(text, max) {
9837
10870
  \u2026[truncated ${text.length - max} chars]`;
9838
10871
  }
9839
10872
  var MAX_REPLAY_MESSAGE_CHARS = 2e3;
9840
- var MAX_REPLAY_LAST_REPLY_CHARS = 12e3;
9841
10873
  var MAX_REPLAY_HEAD_CHARS = 1e3;
10874
+ var MAX_REPLAY_RECENT_MESSAGES = 6;
10875
+ var MAX_REPLAY_RECENT_MESSAGE_CHARS = 12e3;
10876
+ var MAX_REPLAY_TOTAL_CHARS = 18e4;
9842
10877
  function clipMiddle(text, max) {
9843
10878
  if (text.length <= max) return text;
9844
10879
  const head = Math.min(Math.floor(max / 4), MAX_REPLAY_HEAD_CHARS);
@@ -9902,39 +10937,58 @@ ${clipWithMarker(
9902
10937
  }
9903
10938
  return { text: lines.join("\n"), toolResultCount };
9904
10939
  }
10940
+ function isConversationMessage(msg) {
10941
+ return msg.role === "user" || msg.role === "assistant" || msg.role === "tool";
10942
+ }
9905
10943
  function compactConversationHistory(prompt, opts = {}) {
9906
10944
  const mode = opts.mode ?? "fresh-session";
9907
10945
  prompt = filterSideQuestionHistory(prompt);
9908
10946
  if (mode === "compaction") {
9909
10947
  return buildCompactionHistory(prompt);
9910
10948
  }
9911
- const conversationMessages = prompt.filter(
9912
- (m) => m.role === "user" || m.role === "assistant" || m.role === "tool"
9913
- );
10949
+ if (opts.messages) {
10950
+ const run = filterSideQuestionHistory(opts.messages).filter(isConversationMessage);
10951
+ return run.length === 0 ? null : renderReplay(run);
10952
+ }
10953
+ const conversationMessages = prompt.filter(isConversationMessage);
9914
10954
  if (conversationMessages.length <= 1) {
9915
10955
  return null;
9916
10956
  }
9917
- const historyParts = [];
9918
- let lastAssistant = -1;
9919
- for (let i = conversationMessages.length - 2; i >= 0; i--) {
9920
- if (conversationMessages[i].role === "assistant") {
9921
- lastAssistant = i;
9922
- break;
9923
- }
9924
- }
9925
- for (let i = 0; i < conversationMessages.length - 1; i++) {
9926
- const msg = conversationMessages[i];
10957
+ return renderReplay(conversationMessages.slice(0, -1));
10958
+ }
10959
+ function renderReplay(messages) {
10960
+ const entries = [];
10961
+ let total = 0;
10962
+ let rank = 0;
10963
+ let omitted = 0;
10964
+ for (let i = messages.length - 1; i >= 0; i--) {
10965
+ const msg = messages[i];
9927
10966
  const role = msg.role === "user" ? "User" : msg.role === "assistant" ? "Assistant" : "Tool";
9928
10967
  const { text } = renderMessageContentForCompaction(msg);
9929
- if (text.trim()) {
9930
- const max = i === lastAssistant ? MAX_REPLAY_LAST_REPLY_CHARS : MAX_REPLAY_MESSAGE_CHARS;
9931
- historyParts.push(`${role}: ${clipMiddle(text, max)}`);
10968
+ if (!text.trim()) continue;
10969
+ const max = rank < MAX_REPLAY_RECENT_MESSAGES ? MAX_REPLAY_RECENT_MESSAGE_CHARS : MAX_REPLAY_MESSAGE_CHARS;
10970
+ rank++;
10971
+ const entry = `${role}: ${clipMiddle(text, max)}`;
10972
+ if (total + entry.length > MAX_REPLAY_TOTAL_CHARS) {
10973
+ omitted = i + 1;
10974
+ break;
9932
10975
  }
10976
+ entries.push(entry);
10977
+ total += entry.length + 2;
9933
10978
  }
9934
- if (historyParts.length === 0) {
9935
- return null;
10979
+ if (entries.length === 0) return null;
10980
+ entries.reverse();
10981
+ if (omitted > 0) {
10982
+ log.info("replay bounded: the oldest messages were dropped", {
10983
+ omitted,
10984
+ kept: entries.length,
10985
+ chars: total
10986
+ });
10987
+ entries.unshift(
10988
+ `[${omitted} earlier message${omitted === 1 ? "" : "s"} omitted: the replay is capped at ${MAX_REPLAY_TOTAL_CHARS} characters]`
10989
+ );
9936
10990
  }
9937
- return historyParts.join("\n\n");
10991
+ return entries.join("\n\n");
9938
10992
  }
9939
10993
  function buildCompactionHistory(prompt) {
9940
10994
  const entries = [];
@@ -10038,6 +11092,27 @@ ${historyContext}
10038
11092
 
10039
11093
  Now continuing with the current message:
10040
11094
 
11095
+ `
11096
+ });
11097
+ }
11098
+ } else if (opts.missed && opts.missed.length > 0) {
11099
+ const missed = compactConversationHistory(prompt, { messages: opts.missed });
11100
+ if (missed) {
11101
+ log.info("including the messages this claude session missed", {
11102
+ messages: opts.missed.length,
11103
+ chars: missed.length
11104
+ });
11105
+ content.push({
11106
+ type: "text",
11107
+ text: `<missed_conversation>
11108
+ The conversation continued under another opencode agent or model while this session was not asked. These are the messages exchanged since your last reply, replayed as text. They are part of this conversation: decisions and plans made there stand, and work done there is done.
11109
+
11110
+ ${missed}
11111
+
11112
+ </missed_conversation>
11113
+
11114
+ Now continuing with the current message:
11115
+
10041
11116
  `
10042
11117
  });
10043
11118
  }
@@ -10139,13 +11214,13 @@ function getTrailingUserMessages(prompt, opts = {}) {
10139
11214
  // src/skill-bridge.ts
10140
11215
  import * as crypto3 from "crypto";
10141
11216
  import * as fs8 from "fs";
10142
- import * as os4 from "os";
10143
- import * as path10 from "path";
11217
+ import * as os5 from "os";
11218
+ import * as path11 from "path";
10144
11219
  import { fileURLToPath as fileURLToPath3 } from "url";
10145
11220
  function bundledSkillsDir() {
10146
11221
  try {
10147
11222
  const here = fileURLToPath3(import.meta.url);
10148
- const dir = path10.resolve(path10.dirname(here), "..", "skills");
11223
+ const dir = path11.resolve(path11.dirname(here), "..", "skills");
10149
11224
  return dirExists2(dir) ? dir : null;
10150
11225
  } catch {
10151
11226
  return null;
@@ -10162,7 +11237,7 @@ function realPath(p) {
10162
11237
  try {
10163
11238
  return fs8.realpathSync(p);
10164
11239
  } catch {
10165
- return path10.resolve(p);
11240
+ return path11.resolve(p);
10166
11241
  }
10167
11242
  }
10168
11243
  function envEnabled(name) {
@@ -10183,10 +11258,10 @@ function declaredSkillName(source) {
10183
11258
  return SAFE_SKILL_NAME.test(name) ? name : null;
10184
11259
  }
10185
11260
  function readSkill(root, entry) {
10186
- const dir = path10.join(root, entry);
11261
+ const dir = path11.join(root, entry);
10187
11262
  let source;
10188
11263
  try {
10189
- source = fs8.readFileSync(path10.join(dir, "SKILL.md"), "utf8");
11264
+ source = fs8.readFileSync(path11.join(dir, "SKILL.md"), "utf8");
10190
11265
  } catch {
10191
11266
  return null;
10192
11267
  }
@@ -10204,40 +11279,40 @@ function skillRoots(cwd) {
10204
11279
  const roots = [];
10205
11280
  const seen = /* @__PURE__ */ new Set();
10206
11281
  const push = (p) => {
10207
- const abs = path10.resolve(p);
11282
+ const abs = path11.resolve(p);
10208
11283
  if (seen.has(abs)) return;
10209
11284
  seen.add(abs);
10210
11285
  if (dirExists2(abs)) roots.push(abs);
10211
11286
  };
10212
- const home = os4.homedir();
10213
- const homeDir = home ? path10.resolve(home) : null;
10214
- let current = path10.resolve(cwd);
11287
+ const home = os5.homedir();
11288
+ const homeDir = home ? path11.resolve(home) : null;
11289
+ let current = path11.resolve(cwd);
10215
11290
  while (true) {
10216
- push(path10.join(current, ".opencode", "skills"));
11291
+ push(path11.join(current, ".opencode", "skills"));
10217
11292
  if (current !== homeDir) {
10218
- push(path10.join(current, ".claude", "skills"));
10219
- push(path10.join(current, ".agents", "skills"));
11293
+ push(path11.join(current, ".claude", "skills"));
11294
+ push(path11.join(current, ".agents", "skills"));
10220
11295
  }
10221
- const parent = path10.dirname(current);
11296
+ const parent = path11.dirname(current);
10222
11297
  if (parent === current) break;
10223
11298
  current = parent;
10224
11299
  }
10225
- if (homeDir) push(path10.join(homeDir, ".opencode", "skills"));
11300
+ if (homeDir) push(path11.join(homeDir, ".opencode", "skills"));
10226
11301
  const envDir = process.env.OPENCODE_CONFIG_DIR;
10227
11302
  if (envDir) {
10228
- push(path10.join(envDir, "skills"));
10229
- push(path10.join(envDir, "skill"));
11303
+ push(path11.join(envDir, "skills"));
11304
+ push(path11.join(envDir, "skill"));
10230
11305
  }
10231
- const xdg = process.env.XDG_CONFIG_HOME ?? (homeDir ? path10.join(homeDir, ".config") : null);
11306
+ const xdg = process.env.XDG_CONFIG_HOME ?? (homeDir ? path11.join(homeDir, ".config") : null);
10232
11307
  if (xdg) {
10233
- push(path10.join(xdg, "opencode", "skills"));
10234
- push(path10.join(xdg, "opencode", "skill"));
11308
+ push(path11.join(xdg, "opencode", "skills"));
11309
+ push(path11.join(xdg, "opencode", "skill"));
10235
11310
  }
10236
11311
  if (homeDir && !envEnabled("OPENCODE_DISABLE_EXTERNAL_SKILLS")) {
10237
11312
  if (!envEnabled("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")) {
10238
- push(path10.join(homeDir, ".claude", "skills"));
11313
+ push(path11.join(homeDir, ".claude", "skills"));
10239
11314
  }
10240
- push(path10.join(homeDir, ".agents", "skills"));
11315
+ push(path11.join(homeDir, ".agents", "skills"));
10241
11316
  }
10242
11317
  return roots;
10243
11318
  }
@@ -10284,23 +11359,23 @@ function discoverBundledSkills() {
10284
11359
  }
10285
11360
  function claudeConfigDir(configDir) {
10286
11361
  const explicit = configDir ?? process.env.CLAUDE_CONFIG_DIR;
10287
- if (explicit) return path10.resolve(expandHome(explicit));
10288
- const home = os4.homedir();
10289
- return home ? path10.resolve(home, ".claude") : null;
11362
+ if (explicit) return path11.resolve(expandHome(explicit));
11363
+ const home = os5.homedir();
11364
+ return home ? path11.resolve(home, ".claude") : null;
10290
11365
  }
10291
11366
  function installedPluginSkillRoots(configDir, cwd) {
10292
11367
  if (!configDir) return [];
10293
11368
  let parsed;
10294
11369
  try {
10295
11370
  parsed = JSON.parse(
10296
- fs8.readFileSync(path10.join(configDir, "plugins", "installed_plugins.json"), "utf8")
11371
+ fs8.readFileSync(path11.join(configDir, "plugins", "installed_plugins.json"), "utf8")
10297
11372
  );
10298
11373
  } catch {
10299
11374
  return [];
10300
11375
  }
10301
11376
  const plugins = parsed?.plugins;
10302
11377
  if (!plugins || typeof plugins !== "object") return [];
10303
- const workspace = path10.resolve(cwd);
11378
+ const workspace = path11.resolve(cwd);
10304
11379
  const roots = [];
10305
11380
  for (const installs of Object.values(plugins)) {
10306
11381
  if (!Array.isArray(installs)) continue;
@@ -10308,23 +11383,23 @@ function installedPluginSkillRoots(configDir, cwd) {
10308
11383
  const entry = install;
10309
11384
  if (typeof entry?.installPath !== "string" || !entry.installPath) continue;
10310
11385
  if (entry.scope !== "user" && !coversWorkspace(entry.projectPath, workspace)) continue;
10311
- roots.push(path10.join(entry.installPath, "skills"));
11386
+ roots.push(path11.join(entry.installPath, "skills"));
10312
11387
  }
10313
11388
  }
10314
11389
  return roots;
10315
11390
  }
10316
11391
  function coversWorkspace(projectPath, workspace) {
10317
11392
  if (typeof projectPath !== "string" || !projectPath) return false;
10318
- const root = path10.resolve(projectPath);
11393
+ const root = path11.resolve(projectPath);
10319
11394
  if (root === workspace) return true;
10320
- const rel = path10.relative(root, workspace);
10321
- return rel !== "" && !rel.startsWith("..") && !path10.isAbsolute(rel);
11395
+ const rel = path11.relative(root, workspace);
11396
+ return rel !== "" && !rel.startsWith("..") && !path11.isAbsolute(rel);
10322
11397
  }
10323
11398
  function discoverNativeClaudeSkills(opts) {
10324
11399
  const found = [];
10325
11400
  const scanned = /* @__PURE__ */ new Set();
10326
11401
  const scan = (root, scope) => {
10327
- const abs = path10.resolve(root);
11402
+ const abs = path11.resolve(root);
10328
11403
  if (scanned.has(abs)) return;
10329
11404
  scanned.add(abs);
10330
11405
  let entries;
@@ -10341,13 +11416,13 @@ function discoverNativeClaudeSkills(opts) {
10341
11416
  }
10342
11417
  };
10343
11418
  const configDir = claudeConfigDir(opts.configDir);
10344
- if (configDir) scan(path10.join(configDir, "skills"), "user");
10345
- const home = os4.homedir();
10346
- const homeDir = home ? path10.resolve(home) : null;
10347
- let current = path10.resolve(opts.cwd);
11419
+ if (configDir) scan(path11.join(configDir, "skills"), "user");
11420
+ const home = os5.homedir();
11421
+ const homeDir = home ? path11.resolve(home) : null;
11422
+ let current = path11.resolve(opts.cwd);
10348
11423
  while (true) {
10349
- if (current !== homeDir) scan(path10.join(current, ".claude", "skills"), "project");
10350
- const parent = path10.dirname(current);
11424
+ if (current !== homeDir) scan(path11.join(current, ".claude", "skills"), "project");
11425
+ const parent = path11.dirname(current);
10351
11426
  if (parent === current) break;
10352
11427
  current = parent;
10353
11428
  }
@@ -10381,7 +11456,7 @@ function dropNativelyLoadedSkills(skills, native) {
10381
11456
  }
10382
11457
  function readBundledSkillInfos() {
10383
11458
  return discoverBundledSkills().flatMap((skill) => {
10384
- const file = path10.join(skill.dir, "SKILL.md");
11459
+ const file = path11.join(skill.dir, "SKILL.md");
10385
11460
  let text;
10386
11461
  try {
10387
11462
  text = fs8.readFileSync(file, "utf8");
@@ -10425,13 +11500,13 @@ function buildSkillPluginDir(skills) {
10425
11500
  if (skills.length === 0) return null;
10426
11501
  const fingerprint = skills.map((s) => `${s.name}\0${s.dir}`).join("\n");
10427
11502
  const hash = crypto3.createHash("sha256").update(fingerprint).digest("hex").slice(0, 12);
10428
- const root = path10.join(pluginTmpDir(), `skills-${hash}`);
10429
- const manifest = path10.join(root, ".claude-plugin", "plugin.json");
11503
+ const root = path11.join(pluginTmpDir(), `skills-${hash}`);
11504
+ const manifest = path11.join(root, ".claude-plugin", "plugin.json");
10430
11505
  if (fileExists2(manifest)) return root;
10431
11506
  try {
10432
11507
  fs8.rmSync(root, { recursive: true, force: true });
10433
- fs8.mkdirSync(path10.join(root, ".claude-plugin"), { recursive: true });
10434
- fs8.mkdirSync(path10.join(root, "skills"), { recursive: true });
11508
+ fs8.mkdirSync(path11.join(root, ".claude-plugin"), { recursive: true });
11509
+ fs8.mkdirSync(path11.join(root, "skills"), { recursive: true });
10435
11510
  fs8.writeFileSync(
10436
11511
  manifest,
10437
11512
  JSON.stringify(
@@ -10445,7 +11520,7 @@ function buildSkillPluginDir(skills) {
10445
11520
  { encoding: "utf8", mode: 384 }
10446
11521
  );
10447
11522
  for (const skill of skills) {
10448
- linkSkill(skill.dir, path10.join(root, "skills", skill.name));
11523
+ linkSkill(skill.dir, path11.join(root, "skills", skill.name));
10449
11524
  }
10450
11525
  } catch (err) {
10451
11526
  log.warn("failed to stage opencode skill plugin dir", {
@@ -10461,7 +11536,7 @@ function registerBundledSkillPath(config) {
10461
11536
  if (!dir) return false;
10462
11537
  config.skills ??= {};
10463
11538
  const paths = config.skills.paths ??= [];
10464
- if (paths.some((p) => path10.resolve(p) === dir)) return false;
11539
+ if (paths.some((p) => path11.resolve(p) === dir)) return false;
10465
11540
  paths.push(dir);
10466
11541
  return true;
10467
11542
  }
@@ -10509,22 +11584,22 @@ async function resolveSkillPluginDirs(opts) {
10509
11584
  // src/claude-session-wrapper.ts
10510
11585
  import { EventEmitter as EventEmitter4 } from "events";
10511
11586
  import { randomUUID as randomUUID5 } from "crypto";
10512
- import { existsSync as existsSync4, writeFileSync as writeFileSync6 } from "fs";
11587
+ import { existsSync as existsSync4, writeFileSync as writeFileSync7 } from "fs";
10513
11588
  import { unlink as unlink2 } from "fs/promises";
10514
- import { join as join13 } from "path";
11589
+ import { join as join14 } from "path";
10515
11590
 
10516
11591
  // src/claude-session-bun.ts
10517
- import * as os5 from "os";
11592
+ import * as os6 from "os";
10518
11593
  import * as fs9 from "fs";
10519
- import * as path11 from "path";
11594
+ import * as path12 from "path";
10520
11595
  import { execFileSync } from "child_process";
10521
11596
  import { randomUUID as randomUUID4 } from "crypto";
10522
11597
  import { StringDecoder } from "string_decoder";
10523
11598
  function resolveClaude(cmd = "claude") {
10524
- if (path11.isAbsolute(cmd) && fs9.existsSync(cmd)) return cmd;
11599
+ if (path12.isAbsolute(cmd) && fs9.existsSync(cmd)) return cmd;
10525
11600
  const viaBun = Bun.which(cmd);
10526
11601
  if (viaBun) return viaBun;
10527
- const isWin = os5.platform() === "win32";
11602
+ const isWin = os6.platform() === "win32";
10528
11603
  try {
10529
11604
  const out = execFileSync(isWin ? "where" : "which", [cmd], {
10530
11605
  encoding: "utf8",
@@ -10537,7 +11612,7 @@ function resolveClaude(cmd = "claude") {
10537
11612
  throw new Error(`Could not resolve command on PATH: ${cmd}`);
10538
11613
  }
10539
11614
  function encodeCwd(cwd) {
10540
- const resolved = path11.resolve(cwd);
11615
+ const resolved = path12.resolve(cwd);
10541
11616
  let real = resolved;
10542
11617
  try {
10543
11618
  real = fs9.realpathSync.native(resolved);
@@ -10548,7 +11623,7 @@ function encodeCwd(cwd) {
10548
11623
  var bunPtySpawner = (argv, opts) => {
10549
11624
  const [command, ...args] = argv;
10550
11625
  const resolved = resolveClaude(command ?? "claude");
10551
- if (os5.platform() === "win32" && isBatchFile(resolved)) {
11626
+ if (os6.platform() === "win32" && isBatchFile(resolved)) {
10552
11627
  throw new Error(
10553
11628
  `The interactive transport cannot run a Windows batch shim (${resolved}). Point \`cliPath\` at claude.exe, or use the default headless transport.`
10554
11629
  );
@@ -10656,10 +11731,10 @@ function classifyScreen(text) {
10656
11731
  return null;
10657
11732
  }
10658
11733
  function interactiveTranscriptPath(opts) {
10659
- return path11.join(
11734
+ return path12.join(
10660
11735
  resolveConfigDir(opts.configDir),
10661
11736
  "projects",
10662
- encodeCwd(path11.resolve(opts.cwd)),
11737
+ encodeCwd(path12.resolve(opts.cwd)),
10663
11738
  `${opts.sessionId}.jsonl`
10664
11739
  );
10665
11740
  }
@@ -10901,12 +11976,12 @@ function isOwnPromptRecord(rec, normalized) {
10901
11976
  }
10902
11977
  function resolveConfigDir(configDir) {
10903
11978
  const value = configDir ?? process.env.CLAUDE_CONFIG_DIR;
10904
- if (!value) return path11.join(os5.homedir(), ".claude");
10905
- if (value === "~") return os5.homedir();
11979
+ if (!value) return path12.join(os6.homedir(), ".claude");
11980
+ if (value === "~") return os6.homedir();
10906
11981
  if (value.startsWith("~/") || value.startsWith("~\\")) {
10907
- return path11.join(os5.homedir(), value.slice(2));
11982
+ return path12.join(os6.homedir(), value.slice(2));
10908
11983
  }
10909
- return path11.resolve(value);
11984
+ return path12.resolve(value);
10910
11985
  }
10911
11986
  function fatalScreenMessage(kind, configDir, cwd) {
10912
11987
  if (kind === "trust") {
@@ -10970,7 +12045,7 @@ var ClaudeSession = class {
10970
12045
  abandonTurn = false;
10971
12046
  o;
10972
12047
  constructor(opts = {}) {
10973
- this.cwd = path11.resolve(opts.cwd ?? process.cwd());
12048
+ this.cwd = path12.resolve(opts.cwd ?? process.cwd());
10974
12049
  this.configDir = resolveConfigDir(opts.configDir);
10975
12050
  this.explicitConfigDir = opts.configDir ? this.configDir : void 0;
10976
12051
  this.signal = opts.signal;
@@ -10982,7 +12057,7 @@ var ClaudeSession = class {
10982
12057
  this.onExit = opts.onExit;
10983
12058
  this.spawnPty = opts.spawnPty ?? bunPtySpawner;
10984
12059
  this.sessionId = opts.resumeSessionId ?? randomUUID4();
10985
- this.jsonlPath = path11.join(
12060
+ this.jsonlPath = path12.join(
10986
12061
  this.configDir,
10987
12062
  "projects",
10988
12063
  encodeCwd(this.cwd),
@@ -11278,7 +12353,7 @@ var ClaudeSession = class {
11278
12353
  throw new Error("session not started or already exited");
11279
12354
  if (this.turn) throw new Error("a turn is already running in this session");
11280
12355
  let settle;
11281
- this.turn = new Promise((resolve6) => settle = resolve6);
12356
+ this.turn = new Promise((resolve7) => settle = resolve7);
11282
12357
  this.turnDenied = [];
11283
12358
  this.abandonTurn = false;
11284
12359
  this.transcript.skipToEnd();
@@ -11693,8 +12768,8 @@ ${text2}`
11693
12768
  }
11694
12769
  function stageImage(data, extension) {
11695
12770
  try {
11696
- const file = join13(pluginTmpDir(), `attachment-${randomUUID5()}.${extension}`);
11697
- writeFileSync6(file, data, { mode: 384, flag: "wx" });
12771
+ const file = join14(pluginTmpDir(), `attachment-${randomUUID5()}.${extension}`);
12772
+ writeFileSync7(file, data, { mode: 384, flag: "wx" });
11698
12773
  return file;
11699
12774
  } catch (err) {
11700
12775
  log.warn("interactive transport could not stage an image", {
@@ -12175,17 +13250,17 @@ function spawnInteractiveProcess(opts) {
12175
13250
  }
12176
13251
 
12177
13252
  // src/spawn-record-store.ts
12178
- import { mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync4, statSync as statSync7, writeFileSync as writeFileSync7 } from "fs";
12179
- import { homedir as homedir10 } from "os";
12180
- import { join as join14 } from "path";
13253
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync9, renameSync as renameSync5, statSync as statSync8, writeFileSync as writeFileSync8 } from "fs";
13254
+ import { homedir as homedir12 } from "os";
13255
+ import { join as join15 } from "path";
12181
13256
  var MAX_SPAWN_RECORDS = 256;
12182
13257
  var SPAWN_RECORD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
12183
- var pathOverride3 = null;
13258
+ var pathOverride4 = null;
12184
13259
  var lastWritten = /* @__PURE__ */ new Map();
12185
13260
  function spawnRecordStorePath() {
12186
- if (pathOverride3) return pathOverride3;
12187
- const stateRoot = process.env.XDG_STATE_HOME || join14(homedir10(), ".local", "state");
12188
- return join14(stateRoot, "opencode-claude-code-plugin", "session-spawns.json");
13261
+ if (pathOverride4) return pathOverride4;
13262
+ const stateRoot = process.env.XDG_STATE_HOME || join15(homedir12(), ".local", "state");
13263
+ return join15(stateRoot, "opencode-claude-code-plugin", "session-spawns.json");
12189
13264
  }
12190
13265
  function isRecord5(value) {
12191
13266
  const record = value;
@@ -12198,7 +13273,7 @@ function isRecord5(value) {
12198
13273
  function readEntries(file) {
12199
13274
  const out = /* @__PURE__ */ new Map();
12200
13275
  try {
12201
- const parsed = JSON.parse(readFileSync8(file, "utf8"));
13276
+ const parsed = JSON.parse(readFileSync9(file, "utf8"));
12202
13277
  const entries = parsed && typeof parsed === "object" ? parsed.sessions : null;
12203
13278
  if (!entries || typeof entries !== "object") return out;
12204
13279
  const cutoff = Date.now() - SPAWN_RECORD_MAX_AGE_MS;
@@ -12234,12 +13309,12 @@ ${record.account ?? ""}`;
12234
13309
  const cutoff = now - SPAWN_RECORD_MAX_AGE_MS;
12235
13310
  const kept = [...merged.entries()].filter(([, entry]) => entry.at >= cutoff).sort((a, b) => a[1].at - b[1].at).slice(-MAX_SPAWN_RECORDS);
12236
13311
  try {
12237
- mkdirSync6(join14(file, ".."), { recursive: true, mode: 448 });
13312
+ mkdirSync7(join15(file, ".."), { recursive: true, mode: 448 });
12238
13313
  const tmp = `${file}.${process.pid}.tmp`;
12239
- writeFileSync7(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
13314
+ writeFileSync8(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(kept) }), {
12240
13315
  mode: 384
12241
13316
  });
12242
- renameSync4(tmp, file);
13317
+ renameSync5(tmp, file);
12243
13318
  } catch {
12244
13319
  return false;
12245
13320
  }
@@ -12252,7 +13327,7 @@ ${record.account ?? ""}`;
12252
13327
  import { randomUUID as randomUUID6 } from "crypto";
12253
13328
  import { constants as fsConstants } from "fs";
12254
13329
  import { chmod as chmod2, copyFile as copyFile2, lstat as lstat2, mkdir as mkdir2 } from "fs/promises";
12255
- import path12 from "path";
13330
+ import path13 from "path";
12256
13331
  var transcriptPath = interactiveTranscriptPath;
12257
13332
  function configDirForAccount(account) {
12258
13333
  const normalized = normalizeAccountName(account || DEFAULT_ACCOUNT);
@@ -12280,7 +13355,7 @@ async function carryTranscriptToAccount(input) {
12280
13355
  const carriedId = renamed ? (input.newSessionId ?? randomUUID6)() : sessionId;
12281
13356
  const target = renamed ? transcriptPath({ configDir: targetConfigDir, cwd, sessionId: carriedId }) : targetOwn;
12282
13357
  try {
12283
- await mkdir2(path12.dirname(target), { recursive: true, mode: 448 });
13358
+ await mkdir2(path13.dirname(target), { recursive: true, mode: 448 });
12284
13359
  await copyFile2(live.file, target, fsConstants.COPYFILE_EXCL);
12285
13360
  await chmod2(target, 384);
12286
13361
  } catch (err) {
@@ -12300,7 +13375,7 @@ function dedupe2(dirs) {
12300
13375
  const seen = /* @__PURE__ */ new Set();
12301
13376
  const out = [];
12302
13377
  for (const dir of dirs) {
12303
- const resolved = path12.resolve(dir);
13378
+ const resolved = path13.resolve(dir);
12304
13379
  if (seen.has(resolved)) continue;
12305
13380
  seen.add(resolved);
12306
13381
  out.push(resolved);
@@ -12309,13 +13384,13 @@ function dedupe2(dirs) {
12309
13384
  }
12310
13385
 
12311
13386
  // src/prompts.ts
12312
- import { readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
12313
- import { homedir as homedir11 } from "os";
13387
+ import { readFileSync as readFileSync10, writeFileSync as writeFileSync9 } from "fs";
13388
+ import { homedir as homedir13 } from "os";
12314
13389
  import { randomUUID as randomUUID7 } from "crypto";
12315
- import { basename as basename2, dirname as dirname6, isAbsolute as isAbsolute3, join as join15 } from "path";
13390
+ import { basename as basename2, dirname as dirname6, isAbsolute as isAbsolute3, join as join16 } from "path";
12316
13391
  function readPromptFileIfPresent(path15) {
12317
13392
  try {
12318
- const content = readFileSync9(path15, "utf8").trim();
13393
+ const content = readFileSync10(path15, "utf8").trim();
12319
13394
  return content || void 0;
12320
13395
  } catch {
12321
13396
  return void 0;
@@ -12324,7 +13399,7 @@ function readPromptFileIfPresent(path15) {
12324
13399
  function nearestWorkspaceAgentsPrompt(cwd) {
12325
13400
  let dir = cwd;
12326
13401
  while (true) {
12327
- const content = readPromptFileIfPresent(join15(dir, "AGENTS.md"));
13402
+ const content = readPromptFileIfPresent(join16(dir, "AGENTS.md"));
12328
13403
  if (content) return content;
12329
13404
  const parent = dirname6(dir);
12330
13405
  if (parent === dir) return void 0;
@@ -12435,7 +13510,7 @@ var CLAUDE_NATIVE_INSTRUCTION_FILES = /* @__PURE__ */ new Set(["CLAUDE.md", "CLA
12435
13510
  function userAuthoredInstructions(forwarded, opts = {}) {
12436
13511
  const readFile3 = opts.readFile ?? ((path15) => {
12437
13512
  try {
12438
- return readFileSync9(path15, "utf8");
13513
+ return readFileSync10(path15, "utf8");
12439
13514
  } catch {
12440
13515
  return null;
12441
13516
  }
@@ -12476,8 +13551,8 @@ ${options.compressionSummary.trim()}`
12476
13551
  for (const s of extraSystemContent) {
12477
13552
  if (s.trim()) parts.push(s.trim());
12478
13553
  }
12479
- const configRoot = process.env.XDG_CONFIG_HOME ?? join15(homedir11(), ".config");
12480
- const globalAgents = readPromptFileIfPresent(join15(configRoot, "opencode", "AGENTS.md"));
13554
+ const configRoot = process.env.XDG_CONFIG_HOME ?? join16(homedir13(), ".config");
13555
+ const globalAgents = readPromptFileIfPresent(join16(configRoot, "opencode", "AGENTS.md"));
12481
13556
  const workspaceAgents = nearestWorkspaceAgentsPrompt(cwd);
12482
13557
  const forwarded = extraSystemContent.join("\n\n");
12483
13558
  const pushGlobal = !!globalAgents && !forwarded.includes(globalAgents);
@@ -12493,9 +13568,9 @@ ${options.compressionSummary.trim()}`
12493
13568
  if (includeMultiStepHint) parts.push(MULTI_STEP_TASK_HINT);
12494
13569
  const content = parts.join("\n\n");
12495
13570
  if (!content) return void 0;
12496
- const path15 = join15(pluginTmpDir(), `opencode-cc-sys-${randomUUID7()}.md`);
13571
+ const path15 = join16(pluginTmpDir(), `opencode-cc-sys-${randomUUID7()}.md`);
12497
13572
  try {
12498
- writeFileSync8(path15, content, { encoding: "utf8", mode: 384 });
13573
+ writeFileSync9(path15, content, { encoding: "utf8", mode: 384 });
12499
13574
  return path15;
12500
13575
  } catch (err) {
12501
13576
  log.warn("failed to write system prompt file", { error: String(err) });
@@ -13113,12 +14188,43 @@ async function ensureProxyServer(config, tools, sessionKeyForCalls, interceptCom
13113
14188
  };
13114
14189
  });
13115
14190
  }
13116
- const srv = await createProxyMcpServer(tools, timeoutOverrides, interceptors);
14191
+ const cap = config.maxBackgroundSubagents;
14192
+ const admit = cap !== void 0 && tools.some((t) => t.name === "task" || t.name === TASK_BATCH_TOOL_NAME) ? (toolName, input) => admitBackgroundDispatch(toolName, input, cap) : void 0;
14193
+ const srv = await createProxyMcpServer(tools, timeoutOverrides, interceptors, { admit });
13117
14194
  srv.calls.on("call", (call) => {
13118
14195
  queuePendingProxyCall(sessionKeyForCalls, call, timeoutOverrides);
13119
14196
  });
13120
14197
  return srv;
13121
14198
  }
14199
+ async function resolveSpawnCliCaps(caps, cliPath, cliVersion, whenAborted) {
14200
+ const result = {};
14201
+ if (caps.maxTurns !== void 0) {
14202
+ if (cliSupportsMaxTurns(cliVersion)) result.maxTurns = caps.maxTurns;
14203
+ else warnCapDropped("max-turns");
14204
+ }
14205
+ if (caps.maxBudgetUsd !== void 0) {
14206
+ const probe = detectCliSupportsFlag(cliPath, "--max-budget-usd");
14207
+ const supported = whenAborted ? await Promise.race([probe, whenAborted.then(() => false)]) : await probe;
14208
+ if (supported) result.maxBudgetUsd = caps.maxBudgetUsd;
14209
+ else warnCapDropped("max-budget-usd");
14210
+ }
14211
+ return result;
14212
+ }
14213
+ var droppedCapWarnings = /* @__PURE__ */ new Set();
14214
+ function warnCapDropped(cliFlag) {
14215
+ if (droppedCapWarnings.has(cliFlag)) return;
14216
+ droppedCapWarnings.add(cliFlag);
14217
+ log.warn("agent cap not applied: this claude CLI does not take the flag", { cliFlag });
14218
+ }
14219
+ var interactiveCapsWarned = false;
14220
+ function warnCliCapsOnInteractive(caps) {
14221
+ if (interactiveCapsWarned) return;
14222
+ if (caps.maxTurns === void 0 && caps.maxBudgetUsd === void 0) return;
14223
+ interactiveCapsWarned = true;
14224
+ log.warn(
14225
+ "agent maxTurns / maxBudgetUsd not applied: the interactive transport cannot pass print-only flags"
14226
+ );
14227
+ }
13122
14228
 
13123
14229
  // src/mcp-hot-reload.ts
13124
14230
  var DEFAULT_MCP_HOT_RELOAD_COOLDOWN_MS = 6e4;
@@ -13437,8 +14543,8 @@ function watchTurnAbort(signal) {
13437
14543
  let aborted = signal?.aborted === true;
13438
14544
  let listeners = [];
13439
14545
  let settle;
13440
- const whenAborted = new Promise((resolve6) => {
13441
- settle = resolve6;
14546
+ const whenAborted = new Promise((resolve7) => {
14547
+ settle = resolve7;
13442
14548
  });
13443
14549
  const fire = () => {
13444
14550
  if (aborted && listeners === null) return;
@@ -14227,6 +15333,13 @@ function createLineHandler(state, ctx) {
14227
15333
  };
14228
15334
  rememberAccountLimit(ctx.sourceAccount, state.accountLimitHit);
14229
15335
  }
15336
+ if (info?.rateLimitType && info.status) {
15337
+ recordUsageReading(ctx.usageAccount, info.rateLimitType, {
15338
+ utilization: info.utilization ?? null,
15339
+ resetsAt: resetsAtToMs(info.resetsAt),
15340
+ status: info.status
15341
+ });
15342
+ }
14230
15343
  const note = reportRateLimitEvent(msg);
14231
15344
  if (note) {
14232
15345
  state.controller.enqueue({ type: "text-delta", id: state.startTextBlock(), delta: note });
@@ -14514,6 +15627,7 @@ ${plan}
14514
15627
  resultText: msg.message.content.filter((b) => b.type === "text" && b.text).map((b) => String(b.text)).join("\n")
14515
15628
  }));
14516
15629
  const limitErrorReply = limitFromReply && ctx.usageLimitNoteActive;
15630
+ const autoFailoverReply = (limitFromReply || accountBlockKind(msg) !== null) && ctx.autoFailoverTarget() !== void 0;
14517
15631
  const compactionApiError = ctx.compactionMode && (apiErrorReply || typeof msg.error === "string");
14518
15632
  if (limitFromReply) {
14519
15633
  state.accountLimitHit ??= recallAccountLimit(ctx.sourceAccount);
@@ -14526,6 +15640,15 @@ ${plan}
14526
15640
  window: known.window ?? said.window,
14527
15641
  ...known.resetsAt === void 0 && said.resetsText ? { resetsText: said.resetsText } : {}
14528
15642
  };
15643
+ if (ctx.interactive) {
15644
+ const hit = state.accountLimitHit;
15645
+ recordUsageReading(ctx.usageAccount, hit.window ?? "unknown", {
15646
+ utilization: null,
15647
+ resetsAt: resetsAtToMs(hit.resetsAt),
15648
+ resetsText: hit.resetsText,
15649
+ status: "rejected"
15650
+ });
15651
+ }
14529
15652
  }
14530
15653
  if (hasText && !hasToolUse) {
14531
15654
  startResultFallback(state);
@@ -14535,7 +15658,7 @@ ${plan}
14535
15658
  }
14536
15659
  for (const block2 of msg.message.content) {
14537
15660
  if (block2.type === "text" && block2.text) {
14538
- if (limitErrorReply) continue;
15661
+ if (limitErrorReply || autoFailoverReply) continue;
14539
15662
  if (compactionApiError) {
14540
15663
  state.compactionFailureText ??= block2.text;
14541
15664
  continue;
@@ -14755,6 +15878,7 @@ ${plan}
14755
15878
  if (msg.session_id) {
14756
15879
  setClaudeSessionId(state.sessionKey, msg.session_id);
14757
15880
  }
15881
+ if (ctx.budget) recordResultCost(ctx.budget, msg.session_id, msg.total_cost_usd);
14758
15882
  if (deliverPendingCompletions(state)) {
14759
15883
  return;
14760
15884
  }
@@ -14763,9 +15887,11 @@ ${plan}
14763
15887
  })) {
14764
15888
  state.accountLimitHit = recallAccountLimit(ctx.sourceAccount);
14765
15889
  }
15890
+ const autoFailoverResult = msg.is_error === true && (!!state.accountLimitHit || !!state.accountBlock) && ctx.autoFailoverTarget() !== void 0;
14766
15891
  state.usageLimitNote = ctx.usageLimitNoteActive && !!state.accountLimitHit && // An expired login or a billing hold writes its own note just below
14767
15892
  // and says what to run; two notes about one failure is one too many.
14768
- !state.accountBlock && msg.is_error === true;
15893
+ !state.accountBlock && msg.is_error === true && // Moved to another account: that account's answer is this turn's.
15894
+ !autoFailoverResult;
14769
15895
  const compactionFailure = ctx.compactionMode && (msg.is_error === true || typeof msg.subtype === "string" && msg.subtype !== "success");
14770
15896
  if (compactionFailure) {
14771
15897
  if (typeof msg.result === "string" && msg.result.trim()) {
@@ -14774,7 +15900,7 @@ ${plan}
14774
15900
  state.resultFailure ??= state.accountBlock ?? (state.accountLimitHit ? "usage_limit" : void 0) ?? (typeof msg.subtype === "string" && msg.subtype !== "success" ? msg.subtype : "error");
14775
15901
  }
14776
15902
  if (!state.currentTextId && msg.is_error && !compactionFailure && // Already on screen from the reply frame, word for word.
14777
- !(typeof msg.result === "string" && state.apiErrorTextShown !== null && msg.result.trim() === state.apiErrorTextShown.trim()) && !state.usageLimitNote && typeof msg.result === "string" && msg.result.trim().length > 0) {
15903
+ !(typeof msg.result === "string" && state.apiErrorTextShown !== null && msg.result.trim() === state.apiErrorTextShown.trim()) && !state.usageLimitNote && !autoFailoverResult && typeof msg.result === "string" && msg.result.trim().length > 0) {
14778
15904
  const errId = state.startTextBlock();
14779
15905
  state.controller.enqueue({
14780
15906
  type: "text-delta",
@@ -14788,14 +15914,14 @@ ${plan}
14788
15914
  if (state.accountBlock && msg.is_error) {
14789
15915
  state.resultFailure ??= state.accountBlock;
14790
15916
  const offeringSwitch = ctx.failoverAskActive;
14791
- if (!compactionFailure) {
15917
+ if (!compactionFailure && !autoFailoverResult) {
14792
15918
  state.controller.enqueue({
14793
15919
  type: "text-delta",
14794
15920
  id: state.startTextBlock(),
14795
15921
  delta: formatAccountBlockNote({
14796
15922
  kind: state.accountBlock,
14797
15923
  account: ctx.sourceAccount,
14798
- configDir: ctx.config.configDir,
15924
+ configDir: ctx.accountConfigDir,
14799
15925
  offeringSwitch,
14800
15926
  // Read only when the form is not taking the turn, which is the
14801
15927
  // default: moving the work by hand is then the operator's only
@@ -14814,7 +15940,7 @@ ${plan}
14814
15940
  const failure = describeResultFailure(msg);
14815
15941
  if (failure) {
14816
15942
  state.resultFailure = msg.subtype;
14817
- if (!compactionFailure) {
15943
+ if (!compactionFailure && !autoFailoverResult) {
14818
15944
  state.controller.enqueue({
14819
15945
  type: "text-delta",
14820
15946
  id: state.startTextBlock(),
@@ -14857,6 +15983,19 @@ ${plan}
14857
15983
  });
14858
15984
  }
14859
15985
  }
15986
+ if (ctx.budget && !autoFailoverResult && !state.modelRefusal) {
15987
+ const note = takeBudgetNote(ctx.budget);
15988
+ if (note) {
15989
+ state.controller.enqueue({
15990
+ type: "text-delta",
15991
+ id: state.startTextBlock(),
15992
+ delta: note
15993
+ });
15994
+ log.notice("conversation budget threshold crossed", {
15995
+ sessionId: msg.session_id
15996
+ });
15997
+ }
15998
+ }
14860
15999
  state.turnCompleted = true;
14861
16000
  state.endTextBlock();
14862
16001
  const shouldDeferResult = !msg.is_error && !state.autoContinueState.aborted && !state.autoContinueState.sawAskUserQuestion;
@@ -15023,6 +16162,66 @@ var ClaudeCodeLanguageModel = class {
15023
16162
  async resolvePlanModeQuestion(compactionMode, loadLiveToolInfo = () => this.fetchLiveToolInfo()) {
15024
16163
  return resolvePlanModeQuestion(this.config, compactionMode, loadLiveToolInfo);
15025
16164
  }
16165
+ /**
16166
+ * The ledger this turn keeps under `maxBudgetUsd` (h #g245), or null for
16167
+ * none. The parent lookup runs once per session per process and only with a
16168
+ * cap set, so a default install never pays for it. A lookup that cannot
16169
+ * answer reads as "no parent", which makes the session its own conversation:
16170
+ * its spend is still counted, against its own budget.
16171
+ */
16172
+ async resolveTurnBudget(affinity, compactionMode, mode, interactive) {
16173
+ const capUsd = this.config.maxBudgetUsd;
16174
+ if (capUsd === void 0 || !affinity || affinity === "default") return null;
16175
+ if (!sessionParentKnown(affinity)) {
16176
+ noteSessionParent(affinity, await fetchSessionParentId(affinity) ?? null);
16177
+ }
16178
+ return {
16179
+ session: affinity,
16180
+ capUsd,
16181
+ warnAt: this.config.budgetWarnAt ?? [],
16182
+ notes: !compactionMode && mode !== "generate" && !isChildSession(affinity),
16183
+ // The interactive transport synthesizes a per-turn `result` (h #g208);
16184
+ // headless reports the Claude session's running total.
16185
+ cumulative: !interactive
16186
+ };
16187
+ }
16188
+ /**
16189
+ * A turn the session budget stopped before anything was spawned: one
16190
+ * `▌ **budget:**` note (when the turn may write one) and an error finish,
16191
+ * the shape a usage limit has had since h #g214. A child session gets an
16192
+ * `error` part instead of the note, so the parent's `task` result says why
16193
+ * without the note becoming the subagent's answer.
16194
+ */
16195
+ budgetStopStream(opts) {
16196
+ log.notice("conversation budget reached; turn not started", {
16197
+ child: opts.child
16198
+ });
16199
+ const toUsage2 = this.toUsage.bind(this);
16200
+ const text = formatBudgetStopNote(opts.spentUsd, opts.capUsd);
16201
+ const stream = new ReadableStream({
16202
+ start(controller) {
16203
+ controller.enqueue({ type: "stream-start", warnings: opts.warnings });
16204
+ if (opts.note) {
16205
+ const id = generateId();
16206
+ controller.enqueue({ type: "text-start", id });
16207
+ controller.enqueue({ type: "text-delta", id, delta: text });
16208
+ controller.enqueue({ type: "text-end", id });
16209
+ } else if (opts.child) {
16210
+ controller.enqueue({ type: "error", error: new Error(text) });
16211
+ }
16212
+ controller.enqueue({
16213
+ type: "finish",
16214
+ finishReason: { unified: "error", raw: "budget_exceeded" },
16215
+ usage: toUsage2({ input_tokens: 0, output_tokens: 0 }),
16216
+ providerMetadata: {
16217
+ "claude-code": { path: "budget-stop", synthetic: true, usageUnavailable: true }
16218
+ }
16219
+ });
16220
+ controller.close();
16221
+ }
16222
+ });
16223
+ return { stream, request: { body: { text: "" } } };
16224
+ }
15026
16225
  /**
15027
16226
  * A dispatch form, as a turn: one synthetic `question` tool call and a
15028
16227
  * `tool-calls` finish, which is how the operator's answer comes back on the
@@ -15312,12 +16511,93 @@ var ClaudeCodeLanguageModel = class {
15312
16511
  * because which method opencode used is not in them.
15313
16512
  */
15314
16513
  async streamTurn(options, mode) {
15315
- const result = await this.runModelChain(options, mode);
16514
+ const result = await this.runAccountFailover(options, mode);
15316
16515
  return {
15317
16516
  ...result,
15318
16517
  stream: translateStreamForHost(result.stream, this.config.hostApi ?? "v1")
15319
16518
  };
15320
16519
  }
16520
+ /**
16521
+ * Run the turn, moving the same request to the next eligible configured
16522
+ * account when the one it ran on is out of usage or cannot serve
16523
+ * (`accountFailover: "auto"`). The single wiring point for the automatic
16524
+ * switch, and outside `runModelChain` on purpose: an account switch starts
16525
+ * that account's own model chain from the top.
16526
+ *
16527
+ * Anything but `"auto"` is `runModelChain` and one `await`, so `"off"` and
16528
+ * `"ask"` are exactly what they were. With `"auto"`, the attempt that failed
16529
+ * decides the switch itself (`completeResult` sets `run.switchTo` and closes
16530
+ * its stream without a `finish`), and the next attempt's prologue makes the
16531
+ * switch the form's pick would have made. This only splices the attempts
16532
+ * into one caller turn: one `stream-start`, and the `finish` of whichever
16533
+ * attempt did not switch.
16534
+ *
16535
+ * The bound is `run.tried`, as it is `tried` for the model chain: every
16536
+ * account spawned or switched to goes in, and none in it is picked again.
16537
+ *
16538
+ * Cancelling the returned stream is a stop, for every attempt: this loop is
16539
+ * the plugin starting more work on the caller's behalf, so a caller that
16540
+ * has gone away must reach all of it. Each attempt gets a signal that the
16541
+ * caller's own `abortSignal` and a `cancel()` both abort, and the loop
16542
+ * starts no further account once either has happened.
16543
+ */
16544
+ async runAccountFailover(options, mode) {
16545
+ if (this.config.accountFailover !== "auto") return this.runModelChain(options, mode);
16546
+ const self = this;
16547
+ const run = { tried: /* @__PURE__ */ new Set() };
16548
+ const cancelled2 = new AbortController();
16549
+ const attemptOptions = {
16550
+ ...options,
16551
+ abortSignal: options.abortSignal ? AbortSignal.any([options.abortSignal, cancelled2.signal]) : cancelled2.signal
16552
+ };
16553
+ const stopped = () => attemptOptions.abortSignal.aborted;
16554
+ let inner = await this.runModelChain(attemptOptions, mode, run);
16555
+ const request = inner.request;
16556
+ let reader;
16557
+ const stream = new ReadableStream({
16558
+ async start(controller) {
16559
+ let started2 = false;
16560
+ try {
16561
+ for (; ; ) {
16562
+ reader = inner.stream.getReader();
16563
+ try {
16564
+ for (; ; ) {
16565
+ const { done, value } = await reader.read();
16566
+ if (done) break;
16567
+ if (value.type === "stream-start") {
16568
+ if (started2) continue;
16569
+ started2 = true;
16570
+ }
16571
+ controller.enqueue(value);
16572
+ }
16573
+ } finally {
16574
+ reader.releaseLock();
16575
+ }
16576
+ if (!run.switchTo) break;
16577
+ if (stopped()) {
16578
+ log.info("turn stopped before the automatic account switch; not switching", {
16579
+ target: run.switchTo.target
16580
+ });
16581
+ break;
16582
+ }
16583
+ inner = await self.runModelChain(attemptOptions, mode, run);
16584
+ }
16585
+ } catch (error) {
16586
+ if (!stopped()) controller.enqueue({ type: "error", error });
16587
+ } finally {
16588
+ try {
16589
+ controller.close();
16590
+ } catch {
16591
+ }
16592
+ }
16593
+ },
16594
+ async cancel(reason) {
16595
+ cancelled2.abort(reason);
16596
+ await reader?.cancel(reason).catch(() => void 0);
16597
+ }
16598
+ });
16599
+ return { stream, request };
16600
+ }
15321
16601
  /**
15322
16602
  * Run the turn, moving to the next model in the fallback chain if the one
15323
16603
  * it started on is refused. The single wiring point for `src/model-fallback.ts`.
@@ -15336,14 +16616,14 @@ var ClaudeCodeLanguageModel = class {
15336
16616
  * and an exhausted chain leaves the last attempt un-armed so its error
15337
16617
  * surfaces exactly as it does today.
15338
16618
  */
15339
- async runModelChain(options, mode) {
16619
+ async runModelChain(options, mode, accountRun) {
15340
16620
  const agent = this.getOpencodeAgent(options);
15341
16621
  const chain = this.isCompactionCall(options) ? [] : resolveFallbackChain(agent, resolveAgentModel(agent, this.modelId));
15342
- if (chain.length === 0) return this.doStreamForHost(options, void 0, mode);
16622
+ if (chain.length === 0) return this.doStreamForHost(options, void 0, mode, accountRun);
15343
16623
  const self = this;
15344
16624
  const tried = /* @__PURE__ */ new Set();
15345
16625
  let attempt = { armed: true };
15346
- let inner = await this.doStreamForHost(options, attempt, mode);
16626
+ let inner = await this.doStreamForHost(options, attempt, mode, accountRun);
15347
16627
  const request = inner.request;
15348
16628
  const stream = new ReadableStream({
15349
16629
  async start(controller) {
@@ -15413,7 +16693,7 @@ var ClaudeCodeLanguageModel = class {
15413
16693
  modelOverride: next,
15414
16694
  armed: nextFallbackModel(chain, tried) !== void 0
15415
16695
  };
15416
- inner = await self.doStreamForHost(options, attempt, mode);
16696
+ inner = await self.doStreamForHost(options, attempt, mode, accountRun);
15417
16697
  }
15418
16698
  } catch (error) {
15419
16699
  controller.enqueue({ type: "error", error });
@@ -15427,8 +16707,10 @@ var ClaudeCodeLanguageModel = class {
15427
16707
  });
15428
16708
  return { stream, request };
15429
16709
  }
15430
- async doStreamForHost(options, attempt, mode = "stream") {
16710
+ async doStreamForHost(options, attempt, mode = "stream", accountRun) {
15431
16711
  const warnings = [];
16712
+ const autoSwitch = accountRun?.switchTo;
16713
+ if (accountRun) accountRun.switchTo = void 0;
15432
16714
  const turnAbort = watchTurnAbort(options.abortSignal);
15433
16715
  const abortedBeforeWork = (stage) => {
15434
16716
  log.info("abort before the turn asked claude for work; nothing spawned", {
@@ -15466,6 +16748,11 @@ var ClaudeCodeLanguageModel = class {
15466
16748
  this.getOpencodeAgent(options) ?? null
15467
16749
  ];
15468
16750
  if (promptCacheTtl) context.push(promptCacheTtl);
16751
+ const agentCliCaps = compactionMode ? {} : resolveAgentCliCaps(this.getOpencodeAgent(options));
16752
+ if (agentCliCaps.maxTurns !== void 0) context.push(`maxTurns=${agentCliCaps.maxTurns}`);
16753
+ if (agentCliCaps.maxBudgetUsd !== void 0) {
16754
+ context.push(`maxBudgetUsd=${agentCliCaps.maxBudgetUsd}`);
16755
+ }
15469
16756
  const baseKey = sessionKey(
15470
16757
  cwd,
15471
16758
  `${effectiveModelId}::${scope}::${affinity}::context=${JSON.stringify(context)}`
@@ -15523,7 +16810,10 @@ var ClaudeCodeLanguageModel = class {
15523
16810
  configDir: this.config.accountInProcess ? this.config.configDir : void 0,
15524
16811
  // `/claude-code-doctor usage` opts into the CLI's own plan-usage
15525
16812
  // report; anything else here is ignored, as it always has been.
15526
- argument: doctor.rest
16813
+ argument: doctor.rest,
16814
+ maxBudgetUsd: this.config.maxBudgetUsd,
16815
+ budgetWarnAt: this.config.budgetWarnAt,
16816
+ maxBackgroundSubagents: this.config.maxBackgroundSubagents
15527
16817
  };
15528
16818
  const stream2 = new ReadableStream({
15529
16819
  async start(controller) {
@@ -15659,6 +16949,23 @@ var ClaudeCodeLanguageModel = class {
15659
16949
  if (dispatchStep?.kind === "release") {
15660
16950
  return this.subagentDispatchReleaseStream(dispatchStep, sk, warnings);
15661
16951
  }
16952
+ const budget = await this.resolveTurnBudget(affinity, compactionMode, mode, useInteractive);
16953
+ if (budget && !compactionMode) {
16954
+ const root = budgetRoot(budget.session);
16955
+ const spentUsd = sessionSpend(root);
16956
+ if (spentUsd >= budget.capUsd && getPendingProxyCalls(sk).length === 0) {
16957
+ turnAbort.dispose();
16958
+ return this.budgetStopStream({
16959
+ spentUsd,
16960
+ capUsd: budget.capUsd,
16961
+ // The note is the operator's; a `doGenerate` turn's text is a return
16962
+ // value and a child's can become its parent's `task` result.
16963
+ note: mode !== "generate" && !isChildSession(budget.session),
16964
+ child: isChildSession(budget.session),
16965
+ warnings
16966
+ });
16967
+ }
16968
+ }
15662
16969
  if (!compactionMode) invalidateOtherEffortSessions(baseKey, reasoningEffort);
15663
16970
  if (!compactionMode && affinity !== "default") {
15664
16971
  recordSessionSpawn(affinity, {
@@ -15668,12 +16975,17 @@ var ClaudeCodeLanguageModel = class {
15668
16975
  });
15669
16976
  }
15670
16977
  const hasPriorConversation = options.prompt.filter((m) => m.role === "user" || m.role === "assistant").length > 1;
15671
- if (!hasPriorConversation) {
16978
+ if (!hasPriorConversation && !autoSwitch) {
15672
16979
  deleteClaudeSessionId(sk);
15673
16980
  deleteActiveProcess(sk);
15674
16981
  clearCompression(sk);
15675
16982
  }
15676
- const failoverAnswer = compactionMode || mode === "generate" ? null : consumeAccountFailoverAnswer(sk, options.prompt, {
16983
+ const failoverAnswer = autoSwitch ? {
16984
+ kind: "switch",
16985
+ target: autoSwitch.target,
16986
+ sourceAccount: autoSwitch.sourceAccount,
16987
+ resetsAt: autoSwitch.resetsAt
16988
+ } : compactionMode || mode === "generate" ? null : consumeAccountFailoverAnswer(sk, options.prompt, {
15677
16989
  sourceAccount,
15678
16990
  candidates: failoverCandidates(
15679
16991
  this.config.failoverAccounts,
@@ -15731,13 +17043,84 @@ var ClaudeCodeLanguageModel = class {
15731
17043
  cliPath: this.config.cliPath,
15732
17044
  modelId: effectiveModelId
15733
17045
  });
17046
+ while (autoSwitch && accountRun && failover.target !== failoverAnswer.target) {
17047
+ log.notice("automatic account switch skipped an account whose runtime could not be prepared", {
17048
+ sessionKey: sk,
17049
+ target: failoverAnswer.target
17050
+ });
17051
+ const next = nextAutoFailoverAccount(
17052
+ failoverCandidates(this.config.failoverAccounts, sourceAccount, groups),
17053
+ accountRun.tried
17054
+ );
17055
+ if (!next) {
17056
+ clearAccountOverride(failoverAnswer.sourceAccount);
17057
+ log.warn("no configured account could take this turn; it ends here", {
17058
+ sessionKey: sk,
17059
+ account: autoSwitch.failedAccount,
17060
+ cause: autoSwitch.cause
17061
+ });
17062
+ const note = mode === "generate" ? null : autoSwitch.cause === "usage_limit" ? formatUsageLimitNote({
17063
+ sourceAccount: autoSwitch.failedAccount,
17064
+ candidates: [],
17065
+ resetsAt: autoSwitch.resetsAt,
17066
+ window: autoSwitch.window
17067
+ }) : formatAccountBlockNote({
17068
+ kind: autoSwitch.cause,
17069
+ account: autoSwitch.failedAccount,
17070
+ configDir: autoSwitch.failedConfigDir,
17071
+ offeringSwitch: false,
17072
+ candidates: []
17073
+ });
17074
+ const cause = autoSwitch.cause;
17075
+ const exhausted = new ReadableStream({
17076
+ start(controller) {
17077
+ controller.enqueue({ type: "stream-start", warnings });
17078
+ if (note) {
17079
+ const id = generateId();
17080
+ controller.enqueue({ type: "text-start", id });
17081
+ controller.enqueue({ type: "text-delta", id, delta: note });
17082
+ controller.enqueue({ type: "text-end", id });
17083
+ }
17084
+ controller.enqueue({
17085
+ type: "finish",
17086
+ finishReason: { unified: "error", raw: cause },
17087
+ usage: toUsage2({ input_tokens: 0, output_tokens: 0 }),
17088
+ providerMetadata: {
17089
+ "claude-code": {
17090
+ path: "account-failover-exhausted",
17091
+ synthetic: true,
17092
+ usageUnavailable: true
17093
+ }
17094
+ }
17095
+ });
17096
+ controller.close();
17097
+ }
17098
+ });
17099
+ return { stream: exhausted, request: { body: { text: "" } } };
17100
+ }
17101
+ accountRun.tried.add(next);
17102
+ failoverAnswer.target = next;
17103
+ setAccountOverride(
17104
+ failoverAnswer.sourceAccount,
17105
+ next,
17106
+ failoverUntil(failoverAnswer.resetsAt)
17107
+ );
17108
+ failover = await resolveFailoverSpawn({
17109
+ account: sourceAccount,
17110
+ baseCliPath,
17111
+ cliPath: this.config.cliPath,
17112
+ modelId: effectiveModelId
17113
+ });
17114
+ }
15734
17115
  cliPath = failover.cliPath;
15735
17116
  asideTransportRef.cliPath = cliPath;
15736
- failoverNote = formatFailoverNote({
15737
- sourceAccount: failoverAnswer.sourceAccount,
15738
- target: failoverAnswer.target,
15739
- resetsAt: failoverAnswer.resetsAt
15740
- });
17117
+ if (!autoSwitch) {
17118
+ failoverNote = formatFailoverNote({
17119
+ sourceAccount: failoverAnswer.sourceAccount,
17120
+ target: failoverAnswer.target,
17121
+ resetsAt: failoverAnswer.resetsAt
17122
+ });
17123
+ }
15741
17124
  }
15742
17125
  const spawnConfigDir = this.config.accountInProcess ? this.skillBridgeSpawn(failover).configDir : void 0;
15743
17126
  const processForAccount = getActiveProcess(sk);
@@ -15801,6 +17184,15 @@ var ClaudeCodeLanguageModel = class {
15801
17184
  if (failoverAnswer?.kind === "switch" && hasPriorConversation && !conversationOnTarget) {
15802
17185
  includeHistoryContext = true;
15803
17186
  }
17187
+ if (autoSwitch && failoverAnswer?.kind === "switch" && mode !== "generate") {
17188
+ failoverNote = formatAutoFailoverNote({
17189
+ failedAccount: autoSwitch.failedAccount,
17190
+ target: failoverAnswer.target,
17191
+ resetsAt: autoSwitch.resetsAt,
17192
+ reason: autoSwitch.reason,
17193
+ carried: conversationOnTarget
17194
+ });
17195
+ }
15804
17196
  let accountGroupNote = null;
15805
17197
  if (!compactionMode && groups && accountProviders.size > 0 && includeHistoryContext && failoverAnswer?.kind !== "switch") {
15806
17198
  const foreign = findForeignAccountSibling({
@@ -15870,7 +17262,7 @@ var ClaudeCodeLanguageModel = class {
15870
17262
  let replayReason = "no-session-for-key";
15871
17263
  if (this.config.resumeAfterRestart !== false && includeHistoryContext && !compactionMode && !forkFromClaudeSessionId && failoverAnswer?.kind !== "switch") {
15872
17264
  const configDir = resolveConfigDir(this.config.configDir);
15873
- const transcriptPath2 = (id, dir) => join16(dir ?? configDir, "projects", encodeCwd(cwd), `${id}.jsonl`);
17265
+ const transcriptPath2 = (id, dir) => join17(dir ?? configDir, "projects", encodeCwd(cwd), `${id}.jsonl`);
15874
17266
  const resumePoint = findResumePoint({
15875
17267
  sessionKey: sk,
15876
17268
  prompt: options.prompt,
@@ -15973,6 +17365,30 @@ var ClaudeCodeLanguageModel = class {
15973
17365
  messages: options.prompt.length
15974
17366
  });
15975
17367
  }
17368
+ const conversationChange = !compactionMode && !includeHistoryContext && failoverAnswer?.kind !== "switch" && !accountGroupNote && (!!getClaudeSessionId(sk) || !!getActiveProcess(sk)) ? classifyConversationChange(sk, filterSideQuestionHistory(options.prompt)) : null;
17369
+ const missed = conversationChange?.kind === "missed" ? conversationChange : null;
17370
+ if (missed) {
17371
+ log.notice("replaying the messages this claude session missed: the conversation continued under another session key", {
17372
+ sessionKey: sk,
17373
+ messages: missed.messages.length,
17374
+ userMessages: missed.userMessages
17375
+ });
17376
+ }
17377
+ let rewoundNote = null;
17378
+ if (conversationChange?.kind === "rewound") {
17379
+ await deleteActiveProcessAndWait(sk);
17380
+ deleteClaudeSessionId(sk);
17381
+ includeHistoryContext = true;
17382
+ replayReason = "history-rewound";
17383
+ log.notice("replaying the conversation as text: the thread was edited or undone", {
17384
+ sessionKey: sk,
17385
+ reason: "history-rewound",
17386
+ sharedMessages: conversationChange.sharedMessages,
17387
+ removedMessages: conversationChange.removedMessages,
17388
+ removedUserMessages: conversationChange.removedUserMessages
17389
+ });
17390
+ if (mode !== "generate") rewoundNote = formatConversationRewoundNote();
17391
+ }
15976
17392
  if (this.config.forkSessions === true && !compactionMode) {
15977
17393
  recordForkFingerprint(sk, options.prompt, cliPath);
15978
17394
  }
@@ -15993,7 +17409,8 @@ var ClaudeCodeLanguageModel = class {
15993
17409
  compactionMode,
15994
17410
  compactionInstructions: useInteractive && compactionMode ? extractSystemMessages(effectivePrompt).join("\n\n") : void 0,
15995
17411
  cliToolCallIds: new Set(previousPendingProxyCalls.map((c) => c.toolCallId)),
15996
- stripContextReminders: this.stripContextRemindersEnabled()
17412
+ stripContextReminders: this.stripContextRemindersEnabled(),
17413
+ missed: missed?.messages
15997
17414
  });
15998
17415
  const ptyPlanMode = useInteractive && !compactionMode && this.config.permissionMode === "plan";
15999
17416
  const resolvedProxy = compactionMode ? null : ptyPlanMode ? this.resolvedProxyTools()?.filter((def) => def.name !== "write" && def.name !== "edit") ?? null : this.resolvedProxyTools();
@@ -16058,7 +17475,24 @@ var ClaudeCodeLanguageModel = class {
16058
17475
  // the operator is usually not even looking at.
16059
17476
  childSession: !!await fetchSessionParentId(affinity)
16060
17477
  });
17478
+ const spawnedAccount = failover.target ?? sourceAccount;
17479
+ accountRun?.tried.add(spawnedAccount);
17480
+ let autoFailoverChoice;
17481
+ const autoFailoverTarget = () => {
17482
+ if (autoFailoverChoice === void 0) {
17483
+ autoFailoverChoice = accountRun && !compactionMode ? nextAutoFailoverAccount(failoverAccounts, accountRun.tried) ?? null : null;
17484
+ }
17485
+ return autoFailoverChoice ?? void 0;
17486
+ };
17487
+ const limitAccount = accountRun ? spawnedAccount : sourceAccount;
16061
17488
  const usageLimitNoteActive = !compactionMode && mode !== "generate" && !failoverAskActive;
17489
+ const usageWarnThresholds = resolveUsageWarnThresholds(this.config.usageWarnThresholds);
17490
+ const usageSwitchAt = this.config.usageSwitchAt === null ? null : resolveUsageSwitchAt(this.config.usageSwitchAt);
17491
+ const usageNoteThresholds = [
17492
+ .../* @__PURE__ */ new Set([...usageWarnThresholds, ...usageSwitchAt === null ? [] : [usageSwitchAt]])
17493
+ ];
17494
+ const usageNotesPossible = usageNoteThresholds.length > 0 && !compactionMode && mode !== "generate";
17495
+ const usageChildSession = usageNotesPossible ? watchChildSession(affinity) : () => true;
16062
17496
  let staleBuild = null;
16063
17497
  if (!compactionMode && mode !== "generate" && affinity !== "default") {
16064
17498
  staleBuild = staleBuildWatch().check();
@@ -16385,11 +17819,20 @@ var ClaudeCodeLanguageModel = class {
16385
17819
  );
16386
17820
  }
16387
17821
  const questionProxyActive = enrichedProxy?.some((t) => t.name === "question") ?? false;
17822
+ const readRules = resolveReadRuleMapping({
17823
+ layers: opencodeConfigLayers(cwd, self.config.hostApi === "v2" ? "v2" : "v1"),
17824
+ hostApi: self.config.hostApi === "v2" ? "v2" : "v1",
17825
+ enabled: self.config.mapReadRules !== false,
17826
+ worktree: detectWorktree(cwd),
17827
+ cwd
17828
+ });
16388
17829
  const allDisallowed = resolveDisallowedTools({
16389
17830
  proxyTools: enrichedProxy,
16390
17831
  extraDisallowedTools: self.config.extraDisallowedTools,
16391
- disableWebSearch: self.config.webSearch === "disabled"
17832
+ disableWebSearch: self.config.webSearch === "disabled",
17833
+ readRules: readRules.deny
16392
17834
  });
17835
+ recordReadRuleMapping(readRules);
16393
17836
  return {
16394
17837
  excludeServers,
16395
17838
  taskProxyEnabled,
@@ -16500,6 +17943,7 @@ var ClaudeCodeLanguageModel = class {
16500
17943
  supportsBypass: ptyBypassSupported
16501
17944
  });
16502
17945
  if (!posture) throw new Error("the interactive transport cannot hold this permission posture");
17946
+ warnCliCapsOnInteractive(agentCliCaps);
16503
17947
  const ap = spawnInteractiveProcess({
16504
17948
  cwd,
16505
17949
  cliPath,
@@ -16623,7 +18067,8 @@ var ClaudeCodeLanguageModel = class {
16623
18067
  pluginDirs: skillPluginDirs,
16624
18068
  ...self.thinkingCliOptions(),
16625
18069
  fastMode,
16626
- cliVersion
18070
+ cliVersion,
18071
+ ...await resolveSpawnCliCaps(agentCliCaps, cliPath, cliVersion, turnAbort.whenAborted)
16627
18072
  });
16628
18073
  spawnSystemPromptFile = systemPromptFile;
16629
18074
  spawnProxyServer = state.proxyServer;
@@ -16673,6 +18118,14 @@ var ClaudeCodeLanguageModel = class {
16673
18118
  });
16674
18119
  state.endTextBlock();
16675
18120
  }
18121
+ if (rewoundNote) {
18122
+ controller.enqueue({
18123
+ type: "text-delta",
18124
+ id: state.startTextBlock(),
18125
+ delta: rewoundNote
18126
+ });
18127
+ state.endTextBlock();
18128
+ }
16676
18129
  if (accountGroupNote) {
16677
18130
  controller.enqueue({
16678
18131
  type: "text-delta",
@@ -16690,6 +18143,56 @@ var ClaudeCodeLanguageModel = class {
16690
18143
  state.endTextBlock();
16691
18144
  }
16692
18145
  state.autoContinueState.startedAt = Date.now();
18146
+ const writeUsageNote = () => {
18147
+ const crossings = usageCrossings(spawnedAccount, usageNoteThresholds);
18148
+ if (crossings.length === 0) return;
18149
+ const switching = usageSwitchAt !== null && crossings.some((crossing) => crossing.crossed.includes(usageSwitchAt));
18150
+ let movedTo;
18151
+ let candidates = [];
18152
+ if (switching && usageSwitchAt !== null) {
18153
+ if (accountRun) {
18154
+ const target = nextUsageSwitchAccount(
18155
+ failoverAccounts,
18156
+ /* @__PURE__ */ new Set([spawnedAccount, sourceAccount]),
18157
+ usageSwitchAt
18158
+ );
18159
+ if (target) {
18160
+ const until = Math.max(
18161
+ ...crossings.filter((crossing) => crossing.crossed.includes(usageSwitchAt)).map((crossing) => crossing.entry.resetsAt ?? 0)
18162
+ );
18163
+ movedTo = { target, ...until > 0 ? { until } : {} };
18164
+ setAccountOverride(sourceAccount, target, movedTo.until, Date.now(), "usage-threshold");
18165
+ }
18166
+ } else {
18167
+ candidates = failoverAccounts.filter(
18168
+ (account) => account !== spawnedAccount && !accountUsageAtOrOver(account, usageSwitchAt)
18169
+ );
18170
+ }
18171
+ }
18172
+ const windows = crossings.map((crossing) => ({
18173
+ window: crossing.window,
18174
+ percent: usagePercent(crossing.entry.utilization ?? 0),
18175
+ resetsAt: crossing.entry.resetsAt,
18176
+ resetsText: crossing.entry.resetsText
18177
+ }));
18178
+ for (const crossing of crossings) {
18179
+ log.notice("claude account usage crossed a warning threshold", {
18180
+ sessionKey: sk,
18181
+ account: spawnedAccount,
18182
+ rateLimitType: crossing.window,
18183
+ utilization: usagePercent(crossing.entry.utilization ?? 0),
18184
+ crossed: crossing.crossed,
18185
+ switched: movedTo !== void 0
18186
+ });
18187
+ markUsageNoted(spawnedAccount, crossing.window, crossing.crossed);
18188
+ }
18189
+ controller.enqueue({
18190
+ type: "text-delta",
18191
+ id: state.startTextBlock(),
18192
+ delta: formatUsageNote({ account: spawnedAccount, windows, movedTo, candidates })
18193
+ });
18194
+ state.endTextBlock();
18195
+ };
16693
18196
  const completeResult = (msg) => {
16694
18197
  if (state.controllerClosed) return;
16695
18198
  if (self.config.resumeAfterRestart !== false && !compactionMode && msg.is_error !== true && typeof msg.session_id === "string") {
@@ -16698,6 +18201,9 @@ var ClaudeCodeLanguageModel = class {
16698
18201
  account: failover.target ?? sourceAccount
16699
18202
  });
16700
18203
  }
18204
+ if (!compactionMode) {
18205
+ recordServedConversation(sk, filterSideQuestionHistory(options.prompt));
18206
+ }
16701
18207
  if (deliverPendingCompletions(state)) {
16702
18208
  if (state.drainBuffer.length > 0) drainNow(state);
16703
18209
  return;
@@ -16735,6 +18241,36 @@ var ClaudeCodeLanguageModel = class {
16735
18241
  finishWithQuestionCall(state, call);
16736
18242
  return;
16737
18243
  }
18244
+ const autoTarget = (state.accountLimitHit || state.accountBlock) && msg.is_error === true ? autoFailoverTarget() : void 0;
18245
+ if (autoTarget && accountRun) {
18246
+ const target = autoTarget;
18247
+ accountRun.tried.add(target);
18248
+ accountRun.switchTo = {
18249
+ target,
18250
+ sourceAccount,
18251
+ failedAccount: limitAccount,
18252
+ cause: state.accountLimitHit ? "usage_limit" : state.accountBlock,
18253
+ resetsAt: state.accountLimitHit?.resetsAt,
18254
+ window: state.accountLimitHit?.window,
18255
+ failedConfigDir: self.skillBridgeSpawn(failover).configDir,
18256
+ reason: state.accountLimitHit || !state.accountBlock ? void 0 : describeAccountBlock(state.accountBlock)
18257
+ };
18258
+ log.warn("claude account cannot serve this turn; continuing it on the next account automatically", {
18259
+ sessionKey: sk,
18260
+ account: limitAccount,
18261
+ target,
18262
+ cause: state.accountLimitHit ? "usage_limit" : state.accountBlock,
18263
+ resetsAt: state.accountLimitHit?.resetsAt ?? null
18264
+ });
18265
+ state.endTextBlock();
18266
+ state.controllerClosed = true;
18267
+ cleanupTurn();
18268
+ try {
18269
+ controller.close();
18270
+ } catch {
18271
+ }
18272
+ return;
18273
+ }
16738
18274
  if (modelFallbackArmed && attempt && msg.is_error === true) {
16739
18275
  const refusal = state.modelRefusal ?? (state.accountLimitHit && !failoverAskActive && !state.accountBlock ? { kind: "account_limit" } : null);
16740
18276
  if (refusal) {
@@ -16763,19 +18299,20 @@ var ClaudeCodeLanguageModel = class {
16763
18299
  }
16764
18300
  }
16765
18301
  if (state.usageLimitNote) {
18302
+ const noteCandidates = accountRun ? [] : failoverAccounts;
16766
18303
  log.warn("claude account is out of usage; the turn ends with a note", {
16767
18304
  sessionKey: sk,
16768
- account: sourceAccount,
18305
+ account: limitAccount,
16769
18306
  window: state.accountLimitHit?.window ?? null,
16770
18307
  resetsAt: state.accountLimitHit?.resetsAt ?? null,
16771
- candidates: failoverAccounts
18308
+ candidates: noteCandidates
16772
18309
  });
16773
18310
  controller.enqueue({
16774
18311
  type: "text-delta",
16775
18312
  id: state.startTextBlock(),
16776
18313
  delta: formatUsageLimitNote({
16777
- sourceAccount,
16778
- candidates: failoverAccounts,
18314
+ sourceAccount: limitAccount,
18315
+ candidates: noteCandidates,
16779
18316
  resetsAt: state.accountLimitHit?.resetsAt,
16780
18317
  window: state.accountLimitHit?.window,
16781
18318
  resetsText: state.accountLimitHit?.resetsText
@@ -16784,6 +18321,9 @@ var ClaudeCodeLanguageModel = class {
16784
18321
  state.endTextBlock();
16785
18322
  state.resultFailure ??= "usage_limit";
16786
18323
  }
18324
+ if (accountRun && state.accountLimitHit && msg.is_error === true) {
18325
+ state.resultFailure ??= "usage_limit";
18326
+ }
16787
18327
  if (runAutoContinue(state, msg)) return;
16788
18328
  for (const [idx, reasoningId] of state.reasoningIds) {
16789
18329
  if (state.reasoningStarted.get(idx)) {
@@ -16816,6 +18356,9 @@ var ClaudeCodeLanguageModel = class {
16816
18356
  });
16817
18357
  state.endTextBlock();
16818
18358
  }
18359
+ if (usageNotesPossible && msg.is_error !== true && !state.resultFailure && usageChildSession() === false) {
18360
+ writeUsageNote();
18361
+ }
16819
18362
  if (compactionMode && state.resultFailure) {
16820
18363
  log.warn("claude could not compact the conversation; no summary was stored", {
16821
18364
  sessionKey: sk,
@@ -16868,10 +18411,16 @@ var ClaudeCodeLanguageModel = class {
16868
18411
  fastMode,
16869
18412
  planModeQuestionActive,
16870
18413
  interactive: useInteractive,
16871
- sourceAccount,
18414
+ sourceAccount: limitAccount,
18415
+ usageAccount: spawnedAccount,
18416
+ accountConfigDir: accountRun ? self.skillBridgeSpawn(failover).configDir : self.config.configDir,
16872
18417
  failoverAskActive,
18418
+ autoFailoverTarget,
16873
18419
  usageLimitNoteActive,
16874
- failoverAccounts,
18420
+ // Read only when no switch takes the turn, which under `"auto"`
18421
+ // means no account is left to offer.
18422
+ failoverAccounts: accountRun ? [] : failoverAccounts,
18423
+ budget,
16875
18424
  modelFallbackArmed,
16876
18425
  attempt,
16877
18426
  handleControlRequest: handleControlRequest2,
@@ -17122,7 +18671,7 @@ var ClaudeCodeLanguageModel = class {
17122
18671
  return fresh.length;
17123
18672
  };
17124
18673
  if (!state.unattendedTurnEnded && await forwardTrailingUserMessages() > 0) {
17125
- await new Promise((resolve6) => setTimeout(resolve6, FORWARD_SETTLE_MS));
18674
+ await new Promise((resolve7) => setTimeout(resolve7, FORWARD_SETTLE_MS));
17126
18675
  if (state.controllerClosed) return;
17127
18676
  }
17128
18677
  for (const { call, result: opencodeResult } of previousPendingProxyMatches) {
@@ -17226,8 +18775,8 @@ var ClaudeCodeLanguageModel = class {
17226
18775
  };
17227
18776
 
17228
18777
  // src/account-tier.ts
17229
- import { readFileSync as readFileSync10 } from "fs";
17230
- import path13 from "path";
18778
+ import { readFileSync as readFileSync11 } from "fs";
18779
+ import path14 from "path";
17231
18780
  var ORGANIZATION_TYPE_LABELS = {
17232
18781
  claude_max: "Max",
17233
18782
  claude_pro: "Pro",
@@ -17249,14 +18798,14 @@ function accountTierConfigDir(account) {
17249
18798
  function readAccountTier(account) {
17250
18799
  const normalized = normalizeAccountName(account) || DEFAULT_ACCOUNT;
17251
18800
  const configDir = accountTierConfigDir(normalized);
17252
- const cached = tierCache.get(configDir);
17253
- if (cached !== void 0) return cached ?? void 0;
18801
+ const cached2 = tierCache.get(configDir);
18802
+ if (cached2 !== void 0) return cached2 ?? void 0;
17254
18803
  const label = readTierFromConfigDir(configDir, normalized === DEFAULT_ACCOUNT);
17255
18804
  tierCache.set(configDir, label ?? null);
17256
18805
  return label;
17257
18806
  }
17258
18807
  function readTierFromConfigDir(configDir, allowLegacyFallback) {
17259
- const candidates = [path13.join(configDir, ".claude.json")];
18808
+ const candidates = [path14.join(configDir, ".claude.json")];
17260
18809
  if (allowLegacyFallback) candidates.push(expandHome("~/.claude.json"));
17261
18810
  for (const candidate of candidates) {
17262
18811
  const oauth = readOauthAccount(candidate);
@@ -17269,7 +18818,7 @@ function readTierFromConfigDir(configDir, allowLegacyFallback) {
17269
18818
  function readOauthAccount(file) {
17270
18819
  let raw;
17271
18820
  try {
17272
- raw = readFileSync10(file, "utf8");
18821
+ raw = readFileSync11(file, "utf8");
17273
18822
  } catch {
17274
18823
  return void 0;
17275
18824
  }
@@ -17297,14 +18846,14 @@ function tierLabel(oauthAccount) {
17297
18846
  // src/cleanup-stale.ts
17298
18847
  import {
17299
18848
  existsSync as existsSync5,
17300
- mkdirSync as mkdirSync7,
17301
- readFileSync as readFileSync11,
18849
+ mkdirSync as mkdirSync8,
18850
+ readFileSync as readFileSync12,
17302
18851
  realpathSync as realpathSync4,
17303
18852
  rmSync as rmSync3,
17304
- writeFileSync as writeFileSync9
18853
+ writeFileSync as writeFileSync10
17305
18854
  } from "fs";
17306
- import { homedir as homedir12 } from "os";
17307
- import { dirname as dirname7, join as join17, resolve as resolve5 } from "path";
18855
+ import { homedir as homedir14 } from "os";
18856
+ import { dirname as dirname7, join as join18, resolve as resolve6 } from "path";
17308
18857
  import { fileURLToPath as fileURLToPath4 } from "url";
17309
18858
  var STALE_PACKAGE_NAME = "opencode-claude-code-plugin";
17310
18859
  var SUSPECT_DESCRIPTION_TOKEN = "Claude Code";
@@ -17312,20 +18861,20 @@ var alreadyRan = false;
17312
18861
  function candidateCacheRoots() {
17313
18862
  const xdg = process.env.XDG_CACHE_HOME;
17314
18863
  return [
17315
- xdg ? join17(xdg, "opencode") : null,
17316
- join17(homedir12(), ".cache", "opencode"),
17317
- join17(homedir12(), "Library", "Caches", "opencode")
18864
+ xdg ? join18(xdg, "opencode") : null,
18865
+ join18(homedir14(), ".cache", "opencode"),
18866
+ join18(homedir14(), "Library", "Caches", "opencode")
17318
18867
  ].filter((p) => Boolean(p));
17319
18868
  }
17320
18869
  function userOpencodeJsonPath() {
17321
- const xdgConfig = process.env.XDG_CONFIG_HOME ?? join17(homedir12(), ".config");
17322
- return join17(xdgConfig, "opencode", "opencode.json");
18870
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join18(homedir14(), ".config");
18871
+ return join18(xdgConfig, "opencode", "opencode.json");
17323
18872
  }
17324
18873
  function userIntendsToUseUnscoped() {
17325
18874
  const cfg = userOpencodeJsonPath();
17326
18875
  if (!existsSync5(cfg)) return false;
17327
18876
  try {
17328
- const json = JSON.parse(readFileSync11(cfg, "utf8"));
18877
+ const json = JSON.parse(readFileSync12(cfg, "utf8"));
17329
18878
  const plugins = json.plugin;
17330
18879
  if (!Array.isArray(plugins)) return false;
17331
18880
  return plugins.some(
@@ -17338,7 +18887,7 @@ function userIntendsToUseUnscoped() {
17338
18887
  function ourLoadedDir() {
17339
18888
  try {
17340
18889
  const filePath = fileURLToPath4(import.meta.url);
17341
- return realpathSync4(resolve5(filePath, "..", ".."));
18890
+ return realpathSync4(resolve6(filePath, "..", ".."));
17342
18891
  } catch {
17343
18892
  return null;
17344
18893
  }
@@ -17346,19 +18895,19 @@ function ourLoadedDir() {
17346
18895
  function ourVersion(dir) {
17347
18896
  if (!dir) return null;
17348
18897
  try {
17349
- const pkg = JSON.parse(readFileSync11(join17(dir, "package.json"), "utf8"));
18898
+ const pkg = JSON.parse(readFileSync12(join18(dir, "package.json"), "utf8"));
17350
18899
  return typeof pkg.version === "string" && pkg.version ? pkg.version : null;
17351
18900
  } catch {
17352
18901
  return null;
17353
18902
  }
17354
18903
  }
17355
18904
  function markerPath() {
17356
- const stateRoot = process.env.XDG_STATE_HOME ?? join17(homedir12(), ".local", "state");
17357
- return join17(stateRoot, "opencode-claude-code-plugin", "cleanup-stale.json");
18905
+ const stateRoot = process.env.XDG_STATE_HOME ?? join18(homedir14(), ".local", "state");
18906
+ return join18(stateRoot, "opencode-claude-code-plugin", "cleanup-stale.json");
17358
18907
  }
17359
18908
  function markerVersion() {
17360
18909
  try {
17361
- const marker = JSON.parse(readFileSync11(markerPath(), "utf8"));
18910
+ const marker = JSON.parse(readFileSync12(markerPath(), "utf8"));
17362
18911
  return typeof marker.version === "string" ? marker.version : null;
17363
18912
  } catch {
17364
18913
  return null;
@@ -17367,8 +18916,8 @@ function markerVersion() {
17367
18916
  function writeMarker(version) {
17368
18917
  const file = markerPath();
17369
18918
  try {
17370
- mkdirSync7(dirname7(file), { recursive: true });
17371
- writeFileSync9(
18919
+ mkdirSync8(dirname7(file), { recursive: true, mode: 448 });
18920
+ writeFileSync10(
17372
18921
  file,
17373
18922
  JSON.stringify({ version, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
17374
18923
  "utf8"
@@ -17403,7 +18952,7 @@ function cleanupStaleUnscopedInstall() {
17403
18952
  }
17404
18953
  function cleanupOne(cacheRoot, ourDir) {
17405
18954
  if (!existsSync5(cacheRoot)) return;
17406
- const stalePath = join17(cacheRoot, "node_modules", STALE_PACKAGE_NAME);
18955
+ const stalePath = join18(cacheRoot, "node_modules", STALE_PACKAGE_NAME);
17407
18956
  if (!existsSync5(stalePath)) return;
17408
18957
  let realStalePath = stalePath;
17409
18958
  try {
@@ -17411,11 +18960,11 @@ function cleanupOne(cacheRoot, ourDir) {
17411
18960
  } catch {
17412
18961
  }
17413
18962
  if (ourDir && realStalePath === ourDir) return;
17414
- const pkgJsonPath = join17(stalePath, "package.json");
18963
+ const pkgJsonPath = join18(stalePath, "package.json");
17415
18964
  if (!existsSync5(pkgJsonPath)) return;
17416
18965
  let pkg = {};
17417
18966
  try {
17418
- pkg = JSON.parse(readFileSync11(pkgJsonPath, "utf8"));
18967
+ pkg = JSON.parse(readFileSync12(pkgJsonPath, "utf8"));
17419
18968
  } catch {
17420
18969
  return;
17421
18970
  }
@@ -17431,13 +18980,13 @@ function cleanupOne(cacheRoot, ourDir) {
17431
18980
  });
17432
18981
  return;
17433
18982
  }
17434
- const cachePkgJson = join17(cacheRoot, "package.json");
18983
+ const cachePkgJson = join18(cacheRoot, "package.json");
17435
18984
  if (!existsSync5(cachePkgJson)) return;
17436
18985
  try {
17437
- const cfg = JSON.parse(readFileSync11(cachePkgJson, "utf8"));
18986
+ const cfg = JSON.parse(readFileSync12(cachePkgJson, "utf8"));
17438
18987
  if (cfg?.dependencies?.[STALE_PACKAGE_NAME]) {
17439
18988
  delete cfg.dependencies[STALE_PACKAGE_NAME];
17440
- writeFileSync9(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n");
18989
+ writeFileSync10(cachePkgJson, JSON.stringify(cfg, null, 2) + "\n");
17441
18990
  log.info("cleanup-stale: pruned dep from cache package.json");
17442
18991
  }
17443
18992
  } catch (err) {
@@ -17514,7 +19063,6 @@ function registerOpencodeLogSink(client) {
17514
19063
  }
17515
19064
 
17516
19065
  // src/v2-client.ts
17517
- import path14 from "path";
17518
19066
  function formatAgentTypeList(agents) {
17519
19067
  const lines = agents.flatMap((agent) => {
17520
19068
  if (agent.hidden === true || agent.mode === "primary") return [];
@@ -17542,8 +19090,7 @@ function describe(value) {
17542
19090
  function toV1Session(info) {
17543
19091
  if (!info || typeof info !== "object") return void 0;
17544
19092
  const session = info;
17545
- const root = session.location?.directory;
17546
- const directory = typeof root === "string" ? typeof session.subpath === "string" && session.subpath.length > 0 ? path14.resolve(root, session.subpath) : root : void 0;
19093
+ const directory = typeof session.location?.directory === "string" ? session.location.directory : void 0;
17547
19094
  return { ...info, directory, parentID: session.parentID };
17548
19095
  }
17549
19096
  function toV1Messages(messages) {
@@ -18062,6 +19609,12 @@ function createClaudeCode(settings = {}) {
18062
19609
  const cliPath = settings.cliPath ?? process.env.CLAUDE_CLI_PATH ?? "claude";
18063
19610
  const providerName = settings.providerID ?? settings.name ?? "claude-code";
18064
19611
  const proxyTools = preset?.proxyTools ?? settings.proxyTools ?? [...DEFAULT_PROXY_TOOL_NAMES];
19612
+ const maxBudgetUsd = resolveBudgetUsd(settings.maxBudgetUsd, "maxBudgetUsd");
19613
+ const budgetWarnAt = maxBudgetUsd === void 0 ? void 0 : resolveBudgetWarnAt(settings.budgetWarnAt);
19614
+ const maxBackgroundSubagents = resolvePositiveInteger(
19615
+ settings.maxBackgroundSubagents,
19616
+ "maxBackgroundSubagents"
19617
+ );
18065
19618
  const createModel = (modelId) => {
18066
19619
  return new ClaudeCodeLanguageModel(modelId, {
18067
19620
  provider: providerName,
@@ -18104,6 +19657,7 @@ function createClaudeCode(settings = {}) {
18104
19657
  proxyOpencodeTools: settings.proxyOpencodeTools,
18105
19658
  stripContextReminders: settings.stripContextReminders === true,
18106
19659
  extraDisallowedTools: preset?.extraDisallowedTools ?? settings.extraDisallowedTools,
19660
+ mapReadRules: settings.mapReadRules,
18107
19661
  proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
18108
19662
  planModeQuestion: settings.planModeQuestion ?? false,
18109
19663
  webSearch: settings.webSearch,
@@ -18122,6 +19676,13 @@ function createClaudeCode(settings = {}) {
18122
19676
  resumeAfterRestart: settings.resumeAfterRestart !== false,
18123
19677
  resumeAcrossModelChanges: settings.resumeAcrossModelChanges !== false,
18124
19678
  crossAccountResume: settings.crossAccountResume !== false,
19679
+ // Resolved once per model, not per turn, for the reason `accountGroups`
19680
+ // is: an invalid value WARNs at creation and a turn reads a checked one.
19681
+ maxBudgetUsd,
19682
+ budgetWarnAt,
19683
+ maxBackgroundSubagents,
19684
+ usageWarnThresholds: resolveUsageWarnThresholds(settings.usageWarnThresholds),
19685
+ usageSwitchAt: resolveUsageSwitchAt(settings.usageSwitchAt),
18125
19686
  transport: settings.transport,
18126
19687
  interactive: settings.interactive,
18127
19688
  interactiveBypass: settings.interactiveBypass,
@@ -18325,6 +19886,13 @@ async function buildAgentRegistry(config) {
18325
19886
  typeof configuredTtl === "string" ? configuredTtl : void 0
18326
19887
  );
18327
19888
  setProviderFallbackModels(parseFallbackModelList(options?.fallbackModels));
19889
+ setDefaultSubagentCliCaps({
19890
+ maxTurns: resolvePositiveInteger(options?.defaultSubagentMaxTurns, "defaultSubagentMaxTurns"),
19891
+ maxBudgetUsd: resolveBudgetUsd(
19892
+ options?.defaultSubagentMaxBudgetUsd,
19893
+ "defaultSubagentMaxBudgetUsd"
19894
+ )
19895
+ });
18328
19896
  const records2 = await readAgentMarkdownRecords(
18329
19897
  agentDirectories(
18330
19898
  process.env.HOME ?? process.env.USERPROFILE,
@@ -18337,6 +19905,10 @@ async function buildAgentRegistry(config) {
18337
19905
  const value = agent[key] ?? bag[key];
18338
19906
  return typeof value === "string" ? value : void 0;
18339
19907
  };
19908
+ const pickNumber = (key) => {
19909
+ const value = agent[key] ?? bag[key];
19910
+ return typeof value === "number" || typeof value === "string" ? value : void 0;
19911
+ };
18340
19912
  const declaredChain = parseFallbackModelList(
18341
19913
  agent.fallbackModels ?? bag.fallbackModels
18342
19914
  );
@@ -18346,6 +19918,8 @@ async function buildAgentRegistry(config) {
18346
19918
  forceModel: pick("forceModel") ?? records2[name]?.forceModel,
18347
19919
  reasoningEffort: pick("reasoningEffort") ?? records2[name]?.reasoningEffort,
18348
19920
  cacheTtl: pick("cacheTtl") ?? records2[name]?.cacheTtl,
19921
+ maxTurns: pickNumber("maxTurns") ?? records2[name]?.maxTurns,
19922
+ maxBudgetUsd: pickNumber("maxBudgetUsd") ?? records2[name]?.maxBudgetUsd,
18349
19923
  fallbackModels: declaredChain.length ? declaredChain : records2[name]?.fallbackModels,
18350
19924
  prompt: pick("prompt") ?? records2[name]?.prompt
18351
19925
  };