@khalilgharbaoui/opencode-claude-code-plugin 0.50.0 → 0.51.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.d.ts CHANGED
@@ -335,6 +335,9 @@ type OpenCodeHooks = {
335
335
  };
336
336
  type OpenCodePlugin = (input: unknown, options?: Record<string, unknown>) => Promise<OpenCodeHooks>;
337
337
 
338
+ /** Account name -> group name. Empty is never stored; `null` means no guard. */
339
+ type AccountGroups = Readonly<Record<string, string>>;
340
+
338
341
  type LogLevel = "debug" | "info" | "notice" | "warn" | "error";
339
342
  type LogMode = "silent" | "debug";
340
343
 
@@ -390,6 +393,12 @@ interface ClaudeCodeConfig {
390
393
  */
391
394
  accountInProcess?: boolean;
392
395
  accountFailover?: AccountFailoverMode;
396
+ /**
397
+ * The resolved `accountGroups` guard, or null for "no guard". Resolved once
398
+ * by `createClaudeCode` from the user's option, so every turn reads a value
399
+ * that has already been validated and warned about (h #g226).
400
+ */
401
+ accountGroups?: AccountGroups | null;
393
402
  providerID?: string;
394
403
  skipPermissions?: boolean;
395
404
  /**
@@ -531,6 +540,35 @@ interface ClaudeCodeProviderSettings {
531
540
  * error. See README "Account failover".
532
541
  */
533
542
  accountFailover?: AccountFailoverMode;
543
+ /**
544
+ * Which accounts may see each other's conversations. A map from an account
545
+ * name in `accounts` to a group name, e.g. `{ "appical": "work" }`.
546
+ *
547
+ * **Off unless set**, and unset is the default: with no map, or an empty one,
548
+ * every account behaves as one group and nothing about today's behaviour
549
+ * changes. Every account not named here, `default` included, is in one
550
+ * implicit group, so naming the one account that must stay apart is the whole
551
+ * configuration.
552
+ *
553
+ * When it is set, the plugin only ever moves a conversation between accounts
554
+ * in the SAME group: the by-hand switch (picking a model under another
555
+ * account's provider), the account-switch form, the override that form sets,
556
+ * and `crossAccountResume`. A switch ACROSS groups sends the other account
557
+ * nothing at all, not the Claude transcript and not a text replay of the
558
+ * thread either, because the replay is the same history by another route. The
559
+ * turn there starts fresh with only the current message and says so once, in
560
+ * one `▌ **account group:**` note naming both accounts. The conversation is
561
+ * left intact on the account it was on, so switching back continues it.
562
+ *
563
+ * The switch form offers only accounts in the limited account's own group,
564
+ * and with none left it behaves as a single-account install does.
565
+ *
566
+ * An account name that is not in `accounts` is a typo, so it is ignored with
567
+ * one WARN rather than silently creating a group that guards nothing. The
568
+ * resolved map is printed in the startup block and in
569
+ * `/claude-code-doctor`, names only.
570
+ */
571
+ accountGroups?: Record<string, string>;
534
572
  /**
535
573
  * Model that subagents run on when their own definition pins nothing.
536
574
  * Unset means no implicit override at all, so an agent keeps inheriting the
package/dist/index.js CHANGED
@@ -572,6 +572,139 @@ function titleizeAccount(account) {
572
572
  return normalizeAccountName(account).split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
573
573
  }
574
574
 
575
+ // src/account-groups.ts
576
+ var DEFAULT_ACCOUNT_GROUP = "default";
577
+ function normalizeGroupName(value) {
578
+ return value.trim().toLowerCase();
579
+ }
580
+ var warnedUnknownAccounts = /* @__PURE__ */ new Set();
581
+ var warnedShape = false;
582
+ function resolveAccountGroups(value, knownAccounts) {
583
+ if (value === void 0 || value === null) return null;
584
+ if (typeof value !== "object" || Array.isArray(value)) {
585
+ if (!warnedShape) {
586
+ warnedShape = true;
587
+ log.warn(
588
+ "accountGroups must be an object mapping an account name to a group name; ignoring it",
589
+ { type: Array.isArray(value) ? "array" : typeof value }
590
+ );
591
+ }
592
+ return null;
593
+ }
594
+ const known = knownAccounts ? new Set(knownAccounts.map((account) => normalizeAccountName(String(account)))) : void 0;
595
+ const out = {};
596
+ for (const [rawAccount, rawGroup] of Object.entries(value)) {
597
+ const account = normalizeAccountName(rawAccount);
598
+ if (!account) continue;
599
+ if (typeof rawGroup !== "string") {
600
+ if (!warnedShape) {
601
+ warnedShape = true;
602
+ log.warn(
603
+ "accountGroups must be an object mapping an account name to a group name; ignoring it",
604
+ { account, type: typeof rawGroup }
605
+ );
606
+ }
607
+ continue;
608
+ }
609
+ const group = normalizeGroupName(rawGroup);
610
+ if (!group) continue;
611
+ if (known && !known.has(account)) {
612
+ if (!warnedUnknownAccounts.has(account)) {
613
+ warnedUnknownAccounts.add(account);
614
+ log.warn("accountGroups names an account that is not configured; ignoring it", {
615
+ account,
616
+ group
617
+ });
618
+ }
619
+ continue;
620
+ }
621
+ out[account] = group;
622
+ }
623
+ return Object.keys(out).length > 0 ? out : null;
624
+ }
625
+ function accountGroup(account, groups) {
626
+ const name = normalizeAccountName(account || DEFAULT_ACCOUNT);
627
+ return groups?.[name] ?? DEFAULT_ACCOUNT_GROUP;
628
+ }
629
+ function accountsShareGroup(a, b, groups) {
630
+ if (!groups) return true;
631
+ return accountGroup(a, groups) === accountGroup(b, groups);
632
+ }
633
+ function accountsInGroupOf(account, accounts, groups) {
634
+ const out = [];
635
+ for (const raw of accounts ?? []) {
636
+ const name = normalizeAccountName(String(raw));
637
+ if (!name || out.includes(name)) continue;
638
+ if (!accountsShareGroup(account, name, groups)) continue;
639
+ out.push(name);
640
+ }
641
+ return out;
642
+ }
643
+ function describeAccountGroups(groups) {
644
+ if (!groups) return [];
645
+ return Object.entries(groups).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([account, group]) => `${account}=${group}`);
646
+ }
647
+ function accountProviderMap(accounts) {
648
+ const out = /* @__PURE__ */ new Map();
649
+ for (const raw of accounts ?? []) {
650
+ const account = normalizeAccountName(String(raw));
651
+ if (!account) continue;
652
+ out.set(`${BASE_PROVIDER_ID}-${account}`, account);
653
+ }
654
+ return out;
655
+ }
656
+ var CONTEXT_PREFIX = "context=";
657
+ function sessionKeyContextBlob(sessionKey2) {
658
+ const parts = sessionKey2.split("::");
659
+ if (parts.length < 5) return void 0;
660
+ if (parts[2] === "compaction") return void 0;
661
+ const withoutEffort = parts[parts.length - 1].startsWith("effort=") ? parts.slice(0, -1) : parts;
662
+ if (withoutEffort.length < 5) return void 0;
663
+ const tail = withoutEffort.slice(4).join("::");
664
+ return tail.startsWith(CONTEXT_PREFIX) ? tail.slice(CONTEXT_PREFIX.length) : void 0;
665
+ }
666
+ function sessionKeyProvider(sessionKey2) {
667
+ const blob = sessionKeyContextBlob(sessionKey2);
668
+ if (blob === void 0) return void 0;
669
+ try {
670
+ const parsed = JSON.parse(blob);
671
+ const first = Array.isArray(parsed) ? parsed[0] : void 0;
672
+ return typeof first === "string" ? first : void 0;
673
+ } catch {
674
+ return void 0;
675
+ }
676
+ }
677
+ function sessionKeyAccount(sessionKey2, providers) {
678
+ const provider = sessionKeyProvider(sessionKey2);
679
+ return provider === void 0 ? void 0 : providers.get(provider);
680
+ }
681
+ function blankAccountProvider(sessionKey2, providers) {
682
+ if (providers.size === 0) return sessionKey2;
683
+ const blob = sessionKeyContextBlob(sessionKey2);
684
+ if (blob === void 0) return sessionKey2;
685
+ let parsed;
686
+ try {
687
+ parsed = JSON.parse(blob);
688
+ } catch {
689
+ return sessionKey2;
690
+ }
691
+ if (!Array.isArray(parsed)) return sessionKey2;
692
+ const provider = parsed[0];
693
+ if (typeof provider !== "string" || !providers.has(provider)) return sessionKey2;
694
+ const blanked = [...parsed];
695
+ blanked[0] = "*";
696
+ const prefix = sessionKey2.slice(0, sessionKey2.length - blob.length);
697
+ return `${prefix}${JSON.stringify(blanked)}`;
698
+ }
699
+ var ACCOUNT_GROUP_MARKER = "\u258C **account group:**";
700
+ function formatAccountGroupNote(input) {
701
+ const source = normalizeAccountName(input.sourceAccount || DEFAULT_ACCOUNT);
702
+ const target = normalizeAccountName(input.targetAccount || DEFAULT_ACCOUNT);
703
+ return `
704
+ ${ACCOUNT_GROUP_MARKER} this conversation was running on the Claude account "${source}" (group "${input.sourceGroup}") and is now on "${target}" (group "${input.targetGroup}"), so none of it was carried over: accountGroups keeps a conversation inside one group. This account is starting fresh and sees only your latest message. The conversation is still on "${source}"; switch back to it to continue where you stopped.
705
+ `;
706
+ }
707
+
575
708
  // src/types.ts
576
709
  var READ_ONLY_PERMISSION_MODE = "read-only";
577
710
  var DEFAULT_PROXY_TOOL_NAMES = [
@@ -1489,12 +1622,13 @@ function isAccountFailoverQuestionActive(input) {
1489
1622
  if (input.candidates.length === 0) return false;
1490
1623
  return input.opencodeHasQuestion;
1491
1624
  }
1492
- function failoverCandidates(accounts, source) {
1625
+ function failoverCandidates(accounts, source, groups) {
1493
1626
  const from = normalizeAccountName(source || DEFAULT_ACCOUNT);
1494
1627
  const out = [];
1495
1628
  for (const raw of accounts ?? []) {
1496
1629
  const name = normalizeAccountName(String(raw));
1497
1630
  if (!name || name === from || out.includes(name)) continue;
1631
+ if (!accountsShareGroup(from, name, groups)) continue;
1498
1632
  out.push(name);
1499
1633
  }
1500
1634
  return out;
@@ -4544,7 +4678,7 @@ function forkSiblingSignature(sessionKey2) {
4544
4678
  blanked[3] = "*";
4545
4679
  return blanked.join("::");
4546
4680
  }
4547
- function modelSiblingSignature(sessionKey2) {
4681
+ function modelSiblingSignature(sessionKey2, accountProviders) {
4548
4682
  const parts = sessionKey2.split("::");
4549
4683
  if (parts.length < 5) return null;
4550
4684
  if (parts[2] === "compaction") return null;
@@ -4552,7 +4686,8 @@ function modelSiblingSignature(sessionKey2) {
4552
4686
  if (withoutEffort.length < 5) return null;
4553
4687
  const blanked = [...withoutEffort];
4554
4688
  blanked[1] = "*";
4555
- return blanked.join("::");
4689
+ const signature = blanked.join("::");
4690
+ return accountProviders && accountProviders.size > 0 ? blankAccountProvider(signature, accountProviders) : signature;
4556
4691
  }
4557
4692
  function moveForkFingerprint(fromKey, toKey) {
4558
4693
  const record = fingerprints.get(fromKey);
@@ -4657,7 +4792,7 @@ function isDigest(value) {
4657
4792
  }
4658
4793
  function isRecord2(value) {
4659
4794
  const record = value;
4660
- return !!record && typeof record.claudeSessionId === "string" && typeof record.cliPath === "string" && typeof record.updatedAt === "number" && Array.isArray(record.chain) && record.chain.every(isDigest) && (record.shape === void 0 || Array.isArray(record.shape) && record.shape.every(isDigest));
4795
+ return !!record && typeof record.claudeSessionId === "string" && typeof record.cliPath === "string" && typeof record.updatedAt === "number" && Array.isArray(record.chain) && record.chain.every(isDigest) && (record.shape === void 0 || Array.isArray(record.shape) && record.shape.every(isDigest)) && (record.configDir === void 0 || typeof record.configDir === "string") && (record.account === void 0 || typeof record.account === "string");
4661
4796
  }
4662
4797
  function load() {
4663
4798
  if (records) return records;
@@ -4703,17 +4838,27 @@ function save(update) {
4703
4838
  });
4704
4839
  }
4705
4840
  }
4706
- function recordResumePoint(sessionKey2, claudeSessionId, prompt, cliPath) {
4841
+ function recordResumePoint(sessionKey2, claudeSessionId, prompt, cliPath, where) {
4707
4842
  const chain = conversationDigests(prompt);
4708
4843
  if (chain.length === 0) return;
4709
4844
  const shape2 = conversationDigests(prompt, { assistantContent: false });
4845
+ const configDir = where?.configDir;
4846
+ const account = where?.account;
4710
4847
  const current = load().get(sessionKey2);
4711
- if (current && current.claudeSessionId === claudeSessionId && current.cliPath === cliPath && current.shape !== void 0 && current.chain.length === chain.length && current.chain.every((entry, i) => entry.digest === chain[i].digest)) {
4848
+ if (current && current.claudeSessionId === claudeSessionId && current.cliPath === cliPath && current.shape !== void 0 && current.configDir === configDir && current.account === account && current.chain.length === chain.length && current.chain.every((entry, i) => entry.digest === chain[i].digest)) {
4712
4849
  return;
4713
4850
  }
4714
4851
  save((merged) => {
4715
4852
  merged.delete(sessionKey2);
4716
- merged.set(sessionKey2, { claudeSessionId, cliPath, chain, shape: shape2, updatedAt: Date.now() });
4853
+ merged.set(sessionKey2, {
4854
+ claudeSessionId,
4855
+ cliPath,
4856
+ chain,
4857
+ shape: shape2,
4858
+ ...configDir === void 0 ? {} : { configDir },
4859
+ ...account === void 0 ? {} : { account },
4860
+ updatedAt: Date.now()
4861
+ });
4717
4862
  });
4718
4863
  }
4719
4864
  function forgetResumePoint(sessionKey2) {
@@ -4755,18 +4900,31 @@ function findResumePoint(opts) {
4755
4900
  return { claudeSessionId: record.claudeSessionId, matched: record.chain.length };
4756
4901
  }
4757
4902
  function findSiblingResumePoint(opts) {
4758
- const signature = modelSiblingSignature(opts.sessionKey);
4903
+ const providers = opts.accountProviders;
4904
+ const signature = modelSiblingSignature(opts.sessionKey, providers);
4759
4905
  if (!signature) {
4760
4906
  opts.onRefused?.("no-sibling-possible");
4761
4907
  return void 0;
4762
4908
  }
4909
+ const ownAccount = providers ? sessionKeyAccount(opts.sessionKey, providers) : void 0;
4763
4910
  let best;
4764
4911
  let bestUpdatedAt = -1;
4765
4912
  let refusal;
4766
4913
  for (const [key, record] of load()) {
4767
4914
  if (key === opts.sessionKey) continue;
4768
- if (modelSiblingSignature(key) !== signature) continue;
4769
- if (record.cliPath !== opts.cliPath) {
4915
+ if (modelSiblingSignature(key, providers) !== signature) continue;
4916
+ const siblingAccount = providers ? sessionKeyAccount(key, providers) : void 0;
4917
+ const crossAccount = ownAccount !== void 0 && siblingAccount !== void 0 && siblingAccount !== ownAccount;
4918
+ if (crossAccount) {
4919
+ if (opts.allowAccount && !opts.allowAccount(siblingAccount)) {
4920
+ refusal = "sibling-another-account-group";
4921
+ continue;
4922
+ }
4923
+ if (!record.configDir) {
4924
+ refusal = "sibling-account-dir-unknown";
4925
+ continue;
4926
+ }
4927
+ } else if (record.cliPath !== opts.cliPath) {
4770
4928
  refusal = "sibling-another-claude-binary";
4771
4929
  continue;
4772
4930
  }
@@ -4780,7 +4938,7 @@ function findSiblingResumePoint(opts) {
4780
4938
  refusal = "sibling-still-writing";
4781
4939
  continue;
4782
4940
  }
4783
- if (!existsSync2(opts.transcriptPath(record.claudeSessionId))) {
4941
+ if (!existsSync2(opts.transcriptPath(record.claudeSessionId, record.configDir))) {
4784
4942
  refusal = "sibling-transcript-gone";
4785
4943
  continue;
4786
4944
  }
@@ -4789,7 +4947,8 @@ function findSiblingResumePoint(opts) {
4789
4947
  best = {
4790
4948
  claudeSessionId: record.claudeSessionId,
4791
4949
  siblingKey: key,
4792
- matched: record.chain.length
4950
+ matched: record.chain.length,
4951
+ ...crossAccount ? { siblingAccount, siblingConfigDir: record.configDir } : {}
4793
4952
  };
4794
4953
  }
4795
4954
  }
@@ -4800,6 +4959,35 @@ function findSiblingResumePoint(opts) {
4800
4959
  }
4801
4960
  return best;
4802
4961
  }
4962
+ function findForeignAccountSibling(opts) {
4963
+ const providers = opts.accountProviders;
4964
+ if (providers.size === 0) return void 0;
4965
+ const signature = modelSiblingSignature(opts.sessionKey, providers);
4966
+ if (!signature) return void 0;
4967
+ const ownAccount = sessionKeyAccount(opts.sessionKey, providers);
4968
+ if (ownAccount === void 0) return void 0;
4969
+ const consider = (key) => {
4970
+ if (key === opts.sessionKey) return void 0;
4971
+ if (modelSiblingSignature(key, providers) !== signature) return void 0;
4972
+ const account = sessionKeyAccount(key, providers);
4973
+ if (account === void 0 || account === ownAccount) return void 0;
4974
+ return { siblingKey: key, account };
4975
+ };
4976
+ for (const key of opts.extraKeys ?? []) {
4977
+ const hit = consider(key);
4978
+ if (hit) return hit;
4979
+ }
4980
+ let found;
4981
+ let foundUpdatedAt = -1;
4982
+ for (const [key, record] of load()) {
4983
+ const hit = consider(key);
4984
+ if (hit && record.updatedAt >= foundUpdatedAt) {
4985
+ found = hit;
4986
+ foundUpdatedAt = record.updatedAt;
4987
+ }
4988
+ }
4989
+ return found;
4990
+ }
4803
4991
 
4804
4992
  // src/permission-presets.ts
4805
4993
  var READ_ONLY_DISALLOWED_CLI_TOOLS = [
@@ -5472,6 +5660,9 @@ async function deleteActiveProcessAndWait(key, options = {}) {
5472
5660
  function getClaudeSessionId(key) {
5473
5661
  return claudeSessions.get(key);
5474
5662
  }
5663
+ function listClaudeSessionKeys() {
5664
+ return claudeSessions.keys();
5665
+ }
5475
5666
  var MAX_CLAUDE_SESSION_ENTRIES = 64;
5476
5667
  function claudeSessionIsBusy(key) {
5477
5668
  if (activeProcesses.has(key)) return true;
@@ -6151,6 +6342,8 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
6151
6342
  "MCP server asked for operator input and was declined: a headless Claude Code session cannot prompt, so its elicitation can only be refused. Run that server's flow in Claude Code directly, or configure it not to elicit.",
6152
6343
  "MCP tool not routed through the proxy: another proxy tool already holds that name, and it keeps it",
6153
6344
  "account failover declined; ending the turn",
6345
+ "accountGroups must be an object mapping an account name to a group name; ignoring it",
6346
+ "accountGroups names an account that is not configured; ignoring it",
6154
6347
  "agent effort override refused: unknown level",
6155
6348
  "agent model override refused: unknown model",
6156
6349
  "agent prompt cache ttl refused: unknown value",
@@ -6261,6 +6454,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
6261
6454
  "replaying stdout the child emitted between turns",
6262
6455
  "replaying the conversation as text: no claude session was available to continue",
6263
6456
  "replaying this conversation as text on the other account",
6457
+ "replaying this conversation rather than carrying it across accounts",
6264
6458
  "result fallback timer fired \u2014 closing stream without result event",
6265
6459
  "resuming the claude session from before the restart instead of replaying it",
6266
6460
  "shared Claude capability already exists; leaving untouched",
@@ -6268,6 +6462,7 @@ var PLUGIN_LOG_MESSAGES = /* @__PURE__ */ new Set([
6268
6462
  "skipping local MCP server with no command",
6269
6463
  "skipping remote MCP server with no url",
6270
6464
  "stale scratch directory sweep failed",
6465
+ "starting fresh on this account: it is in another account group",
6271
6466
  "task_batch resolving with child results missing",
6272
6467
  "the interactive transport has not been measured on this Claude Code version; it reads the TUI's screen and transcript, which a new release can change. If a turn hangs or a dialog is not handled, run /claude-code-doctor bundle and report it",
6273
6468
  "the interactive turn did not take a forwarded user message",
@@ -6403,7 +6598,13 @@ var BUNDLE_DATA_ALLOWLIST = {
6403
6598
  // doctor table already prints the same class of value (providers, accounts,
6404
6599
  // proxyTools, MCP servers), so a bundle is no more revealing than the report
6405
6600
  // it is attached to.
6601
+ // An account name and a group name are both operator-chosen labels of the
6602
+ // same class as `accounts`, which the doctor table already prints a column
6603
+ // of; `from` is already here as the account a conversation came from.
6604
+ account: "name",
6406
6605
  accounts: "name",
6606
+ accountGroups: "name",
6607
+ group: "name",
6407
6608
  agent: "name",
6408
6609
  bundled: "name",
6409
6610
  excluded: "name",
@@ -7029,6 +7230,15 @@ function collectStartupDiagnostics(providers, opencodeVersion) {
7029
7230
  cwd,
7030
7231
  providers: Object.keys(providers),
7031
7232
  accounts,
7233
+ // One guard per install, so the first provider that declares one answers
7234
+ // for all of them; it is validated against the accounts the expansion
7235
+ // produced, which is the same list every provider carries.
7236
+ accountGroups: describeAccountGroups(
7237
+ resolveAccountGroups(
7238
+ firstOption(providers, "accountGroups"),
7239
+ stringList(firstOption(providers, "failoverAccounts"))
7240
+ )
7241
+ ),
7032
7242
  proxyTools: stringList(firstOption(providers, "proxyTools")),
7033
7243
  mcpServers,
7034
7244
  permissionPresets: Object.entries(providers).map(
@@ -7301,6 +7511,9 @@ function formatDoctorReport(report) {
7301
7511
  lines.push(`| cwd | \`${report.cwd.resolved}\` (${report.cwd.source}) |`);
7302
7512
  lines.push(`| providers | ${list(report.providers)} |`);
7303
7513
  lines.push(`| accounts | ${list(report.accounts)} |`);
7514
+ lines.push(
7515
+ `| accountGroups | ${report.accountGroups.length > 0 ? list(report.accountGroups) : "none (every account is one group)"} |`
7516
+ );
7304
7517
  lines.push(`| proxyTools | ${list(report.proxyTools)} |`);
7305
7518
  lines.push(`| MCP servers (on disk) | ${list(report.mcpServers)} |`);
7306
7519
  lines.push(`| permissionPreset | ${describePermissionPresets(report.permissionPresets)} |`);
@@ -7554,6 +7767,7 @@ async function gatherDoctorReport(options) {
7554
7767
  cwd: base.cwd,
7555
7768
  providers: base.providers,
7556
7769
  accounts: base.accounts,
7770
+ accountGroups: base.accountGroups,
7557
7771
  proxyTools: base.proxyTools,
7558
7772
  mcpServers: base.mcpServers,
7559
7773
  permissionPresets: base.permissionPresets,
@@ -8398,6 +8612,7 @@ var PLUGIN_NOTE_MARKERS = [
8398
8612
  SILENT_TURN_MARKER,
8399
8613
  FAILOVER_MARKER,
8400
8614
  ACCOUNT_BLOCK_MARKER,
8615
+ ACCOUNT_GROUP_MARKER,
8401
8616
  USAGE_LIMIT_MARKER,
8402
8617
  MODEL_FALLBACK_MARKER,
8403
8618
  UNATTENDED_REPLAY_MARKER,
@@ -13930,6 +14145,8 @@ var ClaudeCodeLanguageModel = class {
13930
14145
  const sourceAccount = normalizeAccountName(
13931
14146
  this.config.account ?? DEFAULT_ACCOUNT
13932
14147
  );
14148
+ const accountProviders = accountProviderMap(this.config.failoverAccounts);
14149
+ const groups = this.config.accountGroups ?? null;
13933
14150
  const baseCliPath = this.config.baseCliPath ?? this.config.cliPath;
13934
14151
  let failover = compactionMode ? { cliPath: this.config.cliPath, modelId: effectiveModelId, failedOver: false } : await resolveFailoverSpawn({
13935
14152
  account: sourceAccount,
@@ -14091,7 +14308,11 @@ var ClaudeCodeLanguageModel = class {
14091
14308
  }
14092
14309
  const failoverAnswer = compactionMode || mode === "generate" ? null : consumeAccountFailoverAnswer(sk, options.prompt, {
14093
14310
  sourceAccount,
14094
- candidates: failoverCandidates(this.config.failoverAccounts, sourceAccount)
14311
+ candidates: failoverCandidates(
14312
+ this.config.failoverAccounts,
14313
+ sourceAccount,
14314
+ groups
14315
+ )
14095
14316
  });
14096
14317
  if (failoverAnswer?.kind === "stop") {
14097
14318
  log.warn("account failover declined; ending the turn", {
@@ -14173,8 +14394,13 @@ var ClaudeCodeLanguageModel = class {
14173
14394
  // Every account's own directory, plus this model's configured one:
14174
14395
  // `configDir` is an option, so an account's directory is not always
14175
14396
  // the `~/.claude-<name>` the name alone would build.
14397
+ // Same-group accounts only (h #g226): a transcript sitting in an
14398
+ // account from another group is not a source this may read from, and
14399
+ // with no groups configured this is the full list exactly as before.
14176
14400
  accountConfigDirs: [
14177
- ...(this.config.failoverAccounts ?? []).map(configDirForAccount),
14401
+ ...accountsInGroupOf(sourceAccount, this.config.failoverAccounts, groups).map(
14402
+ configDirForAccount
14403
+ ),
14178
14404
  resolveConfigDir(this.config.configDir)
14179
14405
  ]
14180
14406
  });
@@ -14208,6 +14434,32 @@ var ClaudeCodeLanguageModel = class {
14208
14434
  if (failoverAnswer?.kind === "switch" && hasPriorConversation && !conversationOnTarget) {
14209
14435
  includeHistoryContext = true;
14210
14436
  }
14437
+ let accountGroupNote = null;
14438
+ if (!compactionMode && groups && accountProviders.size > 0 && includeHistoryContext && failoverAnswer?.kind !== "switch") {
14439
+ const foreign = findForeignAccountSibling({
14440
+ sessionKey: sk,
14441
+ accountProviders,
14442
+ // The live process's own view, so the guard holds with
14443
+ // `resumeAfterRestart: false`, where the store is never written.
14444
+ extraKeys: listClaudeSessionKeys()
14445
+ });
14446
+ if (foreign && !accountsShareGroup(sourceAccount, foreign.account, groups)) {
14447
+ includeHistoryContext = false;
14448
+ accountGroupNote = formatAccountGroupNote({
14449
+ sourceAccount: foreign.account,
14450
+ sourceGroup: accountGroup(foreign.account, groups),
14451
+ targetAccount: sourceAccount,
14452
+ targetGroup: accountGroup(sourceAccount, groups)
14453
+ });
14454
+ log.notice("starting fresh on this account: it is in another account group", {
14455
+ sessionKey: sk,
14456
+ reason: "another-account-group",
14457
+ account: sourceAccount,
14458
+ from: foreign.account,
14459
+ group: accountGroup(sourceAccount, groups)
14460
+ });
14461
+ }
14462
+ }
14211
14463
  let forkFromClaudeSessionId;
14212
14464
  if (this.config.forkSessions === true && includeHistoryContext && !compactionMode && failoverAnswer?.kind !== "switch") {
14213
14465
  const parent = findForkParent({
@@ -14251,7 +14503,7 @@ var ClaudeCodeLanguageModel = class {
14251
14503
  let replayReason = "no-session-for-key";
14252
14504
  if (this.config.resumeAfterRestart !== false && includeHistoryContext && !compactionMode && !forkFromClaudeSessionId && failoverAnswer?.kind !== "switch") {
14253
14505
  const configDir = resolveConfigDir(this.config.configDir);
14254
- const transcriptPath2 = (id) => join13(configDir, "projects", encodeCwd(cwd), `${id}.jsonl`);
14506
+ const transcriptPath2 = (id, dir) => join13(dir ?? configDir, "projects", encodeCwd(cwd), `${id}.jsonl`);
14255
14507
  const resumePoint = findResumePoint({
14256
14508
  sessionKey: sk,
14257
14509
  prompt: options.prompt,
@@ -14279,25 +14531,70 @@ var ClaudeCodeLanguageModel = class {
14279
14531
  // timers on everything it touches (the same reason the fork lookup
14280
14532
  // above uses it).
14281
14533
  isBusy: claudeSessionIsWriting,
14534
+ // The ACCOUNT is the third thing that may differ (h #g226), and the
14535
+ // guard is where a cross-group sibling is refused by name.
14536
+ accountProviders,
14537
+ allowAccount: (account) => this.config.crossAccountResume !== false && accountsShareGroup(sourceAccount, account, groups),
14282
14538
  onRefused: (reason) => {
14283
14539
  replayReason = reason;
14284
14540
  }
14285
14541
  });
14286
14542
  if (sibling) {
14287
- deleteActiveProcess(sibling.siblingKey);
14288
- const carried = transferClaudeSession(sibling.siblingKey, sk);
14289
- if (carried) {
14290
- includeHistoryContext = false;
14291
- log.notice(
14292
- "continuing this conversation's claude session under the new model or effort instead of replaying it",
14293
- {
14294
- sessionKey: sk,
14295
- siblingKey: sibling.siblingKey,
14296
- matchedMessages: sibling.matched
14297
- }
14298
- );
14543
+ let carriedSessionId;
14544
+ let carryRefused;
14545
+ if (sibling.siblingAccount && sibling.siblingConfigDir) {
14546
+ const carry = await carryTranscriptToAccount({
14547
+ sessionId: sibling.claudeSessionId,
14548
+ cwd,
14549
+ targetConfigDir: resolveConfigDir(this.skillBridgeSpawn(failover).configDir),
14550
+ // The sibling's recorded directory first, then every same-group
14551
+ // account's. The recorded one can be stale by a turn (it is
14552
+ // rewritten only when a turn succeeds), so the conventional
14553
+ // directories are what make a stale record recoverable rather
14554
+ // than a refusal.
14555
+ accountConfigDirs: [
14556
+ sibling.siblingConfigDir,
14557
+ ...accountsInGroupOf(sourceAccount, this.config.failoverAccounts, groups).map(
14558
+ configDirForAccount
14559
+ ),
14560
+ configDir
14561
+ ]
14562
+ });
14563
+ if (carry.kind === "carried" || carry.kind === "already-there") {
14564
+ carriedSessionId = carry.sessionId;
14565
+ } else {
14566
+ carryRefused = carry.reason;
14567
+ }
14568
+ }
14569
+ if (carryRefused) {
14570
+ replayReason = `sibling-${carryRefused}`;
14571
+ log.notice("replaying this conversation rather than carrying it across accounts", {
14572
+ sessionKey: sk,
14573
+ siblingKey: sibling.siblingKey,
14574
+ reason: carryRefused,
14575
+ account: sourceAccount,
14576
+ from: sibling.siblingAccount
14577
+ });
14299
14578
  } else {
14300
- replayReason = "sibling-carry-over-failed";
14579
+ deleteActiveProcess(sibling.siblingKey);
14580
+ const carried = transferClaudeSession(sibling.siblingKey, sk);
14581
+ if (carried) {
14582
+ if (carriedSessionId && carriedSessionId !== carried) {
14583
+ setClaudeSessionId(sk, carriedSessionId);
14584
+ }
14585
+ includeHistoryContext = false;
14586
+ log.notice(
14587
+ "continuing this conversation's claude session under the new model or effort instead of replaying it",
14588
+ {
14589
+ sessionKey: sk,
14590
+ siblingKey: sibling.siblingKey,
14591
+ matchedMessages: sibling.matched,
14592
+ ...sibling.siblingAccount ? { from: sibling.siblingAccount, account: sourceAccount } : {}
14593
+ }
14594
+ );
14595
+ } else {
14596
+ replayReason = "sibling-carry-over-failed";
14597
+ }
14301
14598
  }
14302
14599
  }
14303
14600
  }
@@ -14379,7 +14676,8 @@ var ClaudeCodeLanguageModel = class {
14379
14676
  }
14380
14677
  const failoverAccounts = failoverCandidates(
14381
14678
  this.config.failoverAccounts,
14382
- sourceAccount
14679
+ sourceAccount,
14680
+ groups
14383
14681
  );
14384
14682
  const failoverAskActive = failoverAccounts.length > 0 && this.config.accountFailover === "ask" && !compactionMode && // A `doGenerate` caller takes the override and reports the plain
14385
14683
  // rate-limit error, never the form. See `TurnMode`.
@@ -14971,6 +15269,14 @@ var ClaudeCodeLanguageModel = class {
14971
15269
  });
14972
15270
  state.endTextBlock();
14973
15271
  }
15272
+ if (accountGroupNote) {
15273
+ controller.enqueue({
15274
+ type: "text-delta",
15275
+ id: state.startTextBlock(),
15276
+ delta: accountGroupNote
15277
+ });
15278
+ state.endTextBlock();
15279
+ }
14974
15280
  if (failoverNote) {
14975
15281
  controller.enqueue({
14976
15282
  type: "text-delta",
@@ -14983,7 +15289,10 @@ var ClaudeCodeLanguageModel = class {
14983
15289
  const completeResult = (msg) => {
14984
15290
  if (state.controllerClosed) return;
14985
15291
  if (self.config.resumeAfterRestart !== false && !compactionMode && msg.is_error !== true && typeof msg.session_id === "string") {
14986
- recordResumePoint(sk, msg.session_id, options.prompt, cliPath);
15292
+ recordResumePoint(sk, msg.session_id, options.prompt, cliPath, {
15293
+ configDir: resolveConfigDir(self.skillBridgeSpawn(failover).configDir),
15294
+ account: failover.target ?? sourceAccount
15295
+ });
14987
15296
  }
14988
15297
  if (deliverPendingCompletions(state)) {
14989
15298
  if (state.drainBuffer.length > 0) drainNow(state);
@@ -16322,6 +16631,12 @@ function applyPermissionPreset(settings, defaultProxyTools) {
16322
16631
  }
16323
16632
  return resolved;
16324
16633
  }
16634
+ function accountGroups(settings) {
16635
+ return resolveAccountGroups(
16636
+ settings.accountGroups,
16637
+ settings.failoverAccounts ?? settings.accounts
16638
+ );
16639
+ }
16325
16640
  function createClaudeCode(settings = {}) {
16326
16641
  if (settings.logging) {
16327
16642
  configureLogger({
@@ -16352,6 +16667,11 @@ function createClaudeCode(settings = {}) {
16352
16667
  // unset option must stay unset rather than being written to one mode or
16353
16668
  // the other in only one of the two entry points (h #g194).
16354
16669
  accountFailover: settings.accountFailover,
16670
+ // Resolved once here rather than per turn, so the WARNs an invalid entry
16671
+ // raises are tied to provider creation and a turn reads a value that is
16672
+ // already validated. `failoverAccounts` is what the expansion configured,
16673
+ // which is exactly the set a group may name (h #g226).
16674
+ accountGroups: accountGroups(settings),
16355
16675
  providerID: settings.providerID,
16356
16676
  skipPermissions: preset?.skipPermissions ?? settings.skipPermissions ?? true,
16357
16677
  permissionMode: preset?.permissionMode ?? settings.permissionMode,