claude-multiacc 2.0.59 → 2.0.61

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.
Files changed (110) hide show
  1. package/README.md +19 -1
  2. package/bin/claude +78 -11
  3. package/bin/claude-accounts +3 -0
  4. package/bin/codex +65 -7
  5. package/bin/codex-accounts +2 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  41. package/lib/common.sh +15 -0
  42. package/lib/mcp_mirror.py +49 -0
  43. package/lib/mcp_mirror.sh +47 -0
  44. package/lib/mcp_registry.py +237 -10
  45. package/package.json +2 -2
  46. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  107. package/tests/run-tests.sh +146 -1
  108. package/tests/test_mcp_mirror.py +148 -0
  109. package/tests/test_mcp_mirror_args.py +129 -0
  110. package/tests/test_mcp_registry.py +234 -3
@@ -33,8 +33,12 @@ Reconcile rules, the same for both providers:
33
33
  any more" needs to hold fleet-wide; a name in `retiredUser` (a learned stock
34
34
  `claude mcp remove -s user`, or `mcp remove --user-only`) leaves project
35
35
  entries alone, exactly as the client itself did;
36
- * anything else the account holds is left alone (an account may carry its own
37
- extras);
36
+ * a server an account holds that no section names, live or retired, is ADOPTED
37
+ into the registry before the reconcile (`adopt`), and so reaches every account:
38
+ some route around the shims' mirror put it there (2026-10-22: the operator's
39
+ `alias claude='claude --dangerously-skip-permissions'` hid every typed
40
+ `claude mcp add` from it, and motionsites sat in 2 of 11 accounts). Kill switch
41
+ `<PROVIDER>_MULTIACC_MCP_ADOPT=0`;
38
42
  * project-scoped entries (`projects` in the registry) exist for claude only:
39
43
  Codex 0.156 reads no per-project config at all.
40
44
 
@@ -73,6 +77,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
73
77
  REGISTRY_FILE = "mcp-servers.json"
74
78
  OVERLAY_FILE = "mcp-servers.local.json"
75
79
  STAMP_FILE = ".mcp-applied"
80
+ #: Every name the pool itself ever wrote into the account (from any section): adoption
81
+ #: never takes one of those back, so a server dropped from a section without a tombstone
82
+ #: (README's recovery for a stray synced entry) or one whose overlay owner vanished is a
83
+ #: stale copy, not a new server — app-robot's machine-specific appinspire-mcp must never
84
+ #: reach the synced registry that way.
85
+ MANAGED_FILE = ".mcp-managed.json"
76
86
  LOCK_DIR = os.path.join(".locks", "mcp")
77
87
  PROVIDERS = ("claude", "codex")
78
88
  CONFIG_FILE = {"claude": ".claude.json", "codex": "config.toml"}
@@ -168,13 +178,14 @@ def _log(root, message):
168
178
  class _Lock:
169
179
  """mkdir lock, the same shape as lib/common.sh mutate_lock (30 s stale)."""
170
180
 
171
- def __init__(self, root):
181
+ def __init__(self, root, wait=60):
172
182
  self.path = os.path.join(root, LOCK_DIR)
173
183
  self.held = False
184
+ self.wait = wait
174
185
 
175
186
  def __enter__(self):
176
187
  os.makedirs(os.path.dirname(self.path), exist_ok=True)
177
- deadline = time.time() + 60
188
+ deadline = time.time() + self.wait
178
189
  while True:
179
190
  try:
180
191
  os.mkdir(self.path)
@@ -864,18 +875,215 @@ def snapshot_codex(account_dir):
864
875
 
865
876
  # ---------------------------------------------------------------- reconcile
866
877
 
878
+ def load_managed(account_dir):
879
+ """{"user": set(names), "projects": {path: set(names)}} — the account's ledger."""
880
+ doc = _read_json(os.path.join(account_dir, MANAGED_FILE), {})
881
+ doc = doc if isinstance(doc, dict) else {}
882
+ user = doc.get("user") if isinstance(doc.get("user"), list) else []
883
+ projects = doc.get("projects") if isinstance(doc.get("projects"), dict) else {}
884
+ return {"user": {str(n) for n in user},
885
+ "projects": {str(p): {str(n) for n in names} for p, names in projects.items()
886
+ if isinstance(names, list)}}
887
+
888
+
889
+ def record_managed(provider, account_dir, eff):
890
+ """Add what this reconcile wrote to the ledger (it only ever grows)."""
891
+ managed = load_managed(account_dir)
892
+ user = managed["user"] | set(eff.servers)
893
+ projects = {path: set(names) for path, names in managed["projects"].items()}
894
+ if provider == "claude":
895
+ for path, spec in eff.projects.items():
896
+ names = set(spec.get("mcpServers") or {})
897
+ if names:
898
+ projects[path] = projects.get(path, set()) | names
899
+ if user == managed["user"] and projects == managed["projects"]:
900
+ return
901
+ try:
902
+ _atomic_write(os.path.join(account_dir, MANAGED_FILE), json.dumps(
903
+ {"user": sorted(user), "projects": {p: sorted(n) for p, n in sorted(projects.items())}},
904
+ indent=2) + "\n")
905
+ except OSError:
906
+ pass
907
+
908
+
867
909
  def apply_account(root, provider, account_dir):
868
910
  eff = effective(load_registry(root), load_overlay(root))
869
911
  if provider == "claude":
870
912
  state = apply_claude(account_dir, eff)
871
913
  else:
872
914
  state = apply_codex(account_dir, eff)
915
+ record_managed(provider, account_dir, eff)
873
916
  write_stamp(root, provider, account_dir)
874
917
  return state
875
918
 
876
919
 
920
+ def _adopt_enabled(provider):
921
+ return os.environ.get(f"{provider.upper()}_MULTIACC_MCP_ADOPT", "1") != "0"
922
+
923
+
924
+ def _unknown(eff, snap, managed=None):
925
+ """What one account's snapshot holds that no section names, live or retired:
926
+ [(scope, path, name, block)] (path None for user scope). A project's `.mcp.json`
927
+ approvals are NOT adopted: one account's click would override another's rejection
928
+ (`disabledMcpjsonServers`) everywhere — only a mirrored `claude mcp` command carries one."""
929
+ managed = managed or {"user": set(), "projects": {}}
930
+ found = []
931
+ for name, block in (snap.get("user") or {}).items():
932
+ if not isinstance(block, dict):
933
+ continue
934
+ if name in eff.servers or name in eff.retired or name in eff.retired_user \
935
+ or name in managed["user"]:
936
+ continue
937
+ found.append(("user", None, name, block))
938
+ if snap.get("provider") != "claude":
939
+ return found
940
+ for path, entry in (snap.get("projects") or {}).items():
941
+ spec = eff.projects.get(path) or {}
942
+ known = set(spec.get("mcpServers") or {}) | set(spec.get("retired") or []) \
943
+ | eff.retired | managed["projects"].get(path, set())
944
+ for name, block in (entry.get("mcpServers") or {}).items():
945
+ if isinstance(block, dict) and name not in known:
946
+ found.append(("project", path, name, block))
947
+ return found
948
+
949
+
950
+ def _still_unknown(eff, scope, path, name):
951
+ if scope == "user":
952
+ return not (name in eff.servers or name in eff.retired or name in eff.retired_user)
953
+ spec = eff.projects.get(path) or {}
954
+ return not (name in (spec.get("mcpServers") or {}) or name in (spec.get("retired") or [])
955
+ or name in eff.retired)
956
+
957
+
958
+ def _config_mtime(provider, account_dir):
959
+ try:
960
+ return os.stat(os.path.join(account_dir, CONFIG_FILE[provider])).st_mtime
961
+ except OSError:
962
+ return 0.0
963
+
964
+
965
+ def _codex_faithful(name, table):
966
+ """Whether the registry's codex translation gives this table back as it is (bar the
967
+ startup timeout default it adds to a stdio server): only then is a codex table adopted,
968
+ because the reconcile then rewrites the user's own table in that translation."""
969
+ try:
970
+ again, _notes = codex_block(name, codex_to_claude(name, table))
971
+ except RegistryError:
972
+ return False
973
+ if again is None:
974
+ return False
975
+ again = dict(again)
976
+ if "startup_timeout_sec" not in table and again.get("startup_timeout_sec") == 60:
977
+ again.pop("startup_timeout_sec")
978
+ if not table.get("args") and again.get("args") == []:
979
+ again.pop("args", None)
980
+ return again == {k: v for k, v in table.items()}
981
+
982
+
983
+ def _carries_values(block):
984
+ """A block whose env or headers hold literal values — a token, an API key — stays on
985
+ this machine: the synced registry is a plain file pushed to every peer."""
986
+ for key in ("env", "headers"):
987
+ values = block.get(key)
988
+ if isinstance(values, dict) and any(str(v) and not str(v).startswith("${")
989
+ for v in values.values()):
990
+ return True
991
+ return False
992
+
993
+
994
+ def adopt(root, provider, account_dirs, dry=False, lock_wait=60):
995
+ """Take every server these accounts hold that no section names into the registry
996
+ (on a replica: this machine's overlay, like any learned change). The mirror
997
+ (`learn`) sees only a `mcp add` the shim recognised; anything else — a global
998
+ option in front of `mcp` (the operator's aliases), the real binary run directly, a
999
+ hand edit, another tool's installer — left the server in the one account it ran
1000
+ under, for ever. Several accounts holding different definitions of one name: the
1001
+ one most of them hold wins, then the newest config file. Returns the change list
1002
+ (dry: the names it would adopt, nothing written)."""
1003
+ if not account_dirs or not _adopt_enabled(provider):
1004
+ return []
1005
+ eff = effective(load_registry(root), load_overlay(root))
1006
+ variants = {} # (scope, path, name) -> {canonical json: [count, newest mtime, block]}
1007
+ # Every account votes, whichever one this run reconciles: a single-account apply (the
1008
+ # shim's, before exec) must choose what `apply --all` would, never its own stale copy.
1009
+ voters = list(dict.fromkeys(list(account_dirs) + manifest_account_dirs(root)))
1010
+ for account_dir in voters:
1011
+ try:
1012
+ snap = snapshot(provider, account_dir)
1013
+ except RegistryError:
1014
+ continue # apply reports an unreadable config itself
1015
+ mtime = _config_mtime(provider, account_dir)
1016
+ for scope, path, name, block in _unknown(eff, snap, load_managed(account_dir)):
1017
+ slot = variants.setdefault((scope, path, name), {})
1018
+ key = json.dumps(block, sort_keys=True)
1019
+ count = slot.setdefault(key, [0, 0.0, block])
1020
+ count[0] += 1
1021
+ count[1] = max(count[1], mtime)
1022
+ if not variants or dry:
1023
+ return sorted(name for _scope, _path, name in variants) # dry: what would be adopted
1024
+ changes = []
1025
+ synced_changed = local_changed = False
1026
+ with _Lock(root, wait=lock_wait):
1027
+ target = _Target(root)
1028
+ local_overlay = target.overlay if target.replica else load_overlay(root)
1029
+ eff = effective(load_registry(root), load_overlay(root)) # what holds under the lock
1030
+ for (scope, path, name), slot in sorted(variants.items(), key=lambda kv: (
1031
+ kv[0][0], kv[0][1] or "", kv[0][2])):
1032
+ if not _still_unknown(eff, scope, path, name):
1033
+ continue # named meanwhile (a concurrent add, another machine's push)
1034
+ count, _mtime, block = max(slot.values(), key=lambda v: (v[0], v[1]))
1035
+ if provider == "codex" and not _codex_faithful(name, block):
1036
+ _warn(f'not adopted: "{name}" — the registry cannot express its codex table '
1037
+ f"as it is; register it with: codex-accounts mcp add")
1038
+ continue
1039
+ try:
1040
+ learned = block if provider == "claude" else codex_to_claude(name, block)
1041
+ validate_block(name, learned)
1042
+ except RegistryError as exc:
1043
+ _warn(f"not adopted: {exc}")
1044
+ continue
1045
+ where = f" (project {path})" if path else ""
1046
+ if _carries_values(learned) and not target.replica:
1047
+ local = local_overlay.setdefault("owners", {}).setdefault(
1048
+ "local", _normalize_section({}))
1049
+ _upsert(local, name, learned, scope, path)
1050
+ local_changed = True
1051
+ where += " (this machine only: it carries env/header values)"
1052
+ else:
1053
+ _upsert(target.section, name, learned, scope, path)
1054
+ synced_changed = True
1055
+ if len(slot) > 1:
1056
+ changes.append(f'"{name}"{where} (the definition {count} of '
1057
+ f'{sum(v[0] for v in slot.values())} accounts hold)')
1058
+ else:
1059
+ changes.append(f'"{name}"{where}')
1060
+ if synced_changed:
1061
+ target.save()
1062
+ if local_changed:
1063
+ save_overlay(root, local_overlay)
1064
+ if changes:
1065
+ _log(root, f"adopt {', '.join(changes)} into {target.where}")
1066
+ return changes
1067
+
1068
+
877
1069
  def apply_many(root, provider, account_dirs, quiet=False, fail_open=False):
878
- """Reconcile each dir; returns the number of accounts that could not be."""
1070
+ """Adopt what these accounts hold that no section names, then reconcile each dir;
1071
+ returns the number of accounts that could not be."""
1072
+ try:
1073
+ # a launch must not wait long on a lock left by a killed holder: the next launch
1074
+ # or the limits pass adopts instead
1075
+ adopted = adopt(root, provider, account_dirs, lock_wait=2 if fail_open else 60)
1076
+ except Exception as exc: # noqa: BLE001 — adoption must never stop a reconcile
1077
+ adopted = []
1078
+ _warn(f"adopt: {type(exc).__name__}: {exc}")
1079
+ if adopted and not quiet:
1080
+ print(f"adopted {', '.join(adopted)}: every {provider} account gets "
1081
+ f"{'it' if len(adopted) == 1 else 'them'}")
1082
+ if adopted and len(account_dirs) < len(manifest_account_dirs(root)):
1083
+ # One account told the registry something new: the others need it as well (the
1084
+ # shims would bring it to each on its next launch; an idle one would wait).
1085
+ # (a dir being seeded is not in the manifest yet: it stays in the list)
1086
+ account_dirs = list(dict.fromkeys(list(account_dirs) + manifest_account_dirs(root)))
879
1087
  failures = 0
880
1088
  for account_dir in account_dirs:
881
1089
  label = os.path.basename(account_dir.rstrip("/"))
@@ -1194,7 +1402,8 @@ def cmd_apply(args, leftovers):
1194
1402
  if args.all or not args.account_dir:
1195
1403
  rc = _apply_all(args, quiet=args.quiet)
1196
1404
  return 0 if args.fail_open else rc
1197
- failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet)
1405
+ failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet,
1406
+ fail_open=args.fail_open)
1198
1407
  return 0 if args.fail_open or not failures else 1
1199
1408
 
1200
1409
 
@@ -1204,14 +1413,18 @@ def cmd_snapshot(args):
1204
1413
 
1205
1414
 
1206
1415
  def cmd_learn(args):
1416
+ from mcp_mirror import scoped_snapshots
1417
+
1207
1418
  try:
1208
1419
  with open(args.before, encoding="utf-8") as handle:
1209
1420
  before = json.load(handle)
1210
1421
  except (OSError, ValueError) as exc:
1211
1422
  raise RegistryError(f"snapshot {args.before}: {exc}") from exc
1212
- after = snapshot(args.provider, args.account_dir)
1213
1423
  with _Lock(args.root):
1424
+ after = snapshot(args.provider, args.account_dir)
1214
1425
  target = _Target(args.root)
1426
+ before, after = scoped_snapshots(before, after, args.name, load_overlay(args.root),
1427
+ "local" if target.replica else None)
1215
1428
  changes = _learn_into(target.section, before, after)
1216
1429
  if changes:
1217
1430
  target.save()
@@ -1251,8 +1464,12 @@ def cmd_import_local(args):
1251
1464
  # it: an owner that stops shipping a server means it, and every account
1252
1465
  # would otherwise keep the stale copy for ever.
1253
1466
  dropped = [name for name in previous["mcpServers"] if name not in servers]
1467
+ # ...and stays retired until it publishes the name again: an account still holding
1468
+ # a copy must never have it adopted into the synced registry (adopt) once the next
1469
+ # import has forgotten the drop.
1470
+ kept = [name for name in previous["retired"] if name not in servers]
1254
1471
  section = _normalize_section({"mcpServers": servers,
1255
- "retired": sorted(set(retired) | set(dropped))})
1472
+ "retired": sorted(set(retired) | set(dropped) | set(kept))})
1256
1473
  if previous == section and owner in overlay["owners"]:
1257
1474
  changed = False
1258
1475
  else:
@@ -1306,7 +1523,8 @@ def build_parser():
1306
1523
  p = modes.add_parser("learn")
1307
1524
  p.add_argument("--account-dir", required=True)
1308
1525
  p.add_argument("--before", required=True)
1309
- p.add_argument("--project", help="accepted for the shim's sake; every changed project is mirrored")
1526
+ p.add_argument("--name", help="mirror only this stock command's server, including project approvals")
1527
+ p.add_argument("--project", help="accepted for the shim; --name applies across project entries")
1310
1528
 
1311
1529
  p = modes.add_parser("import-local")
1312
1530
  p.add_argument("--owner", required=True)
@@ -1330,7 +1548,16 @@ def main(argv=None):
1330
1548
  from codex_python import require_python311
1331
1549
  require_python311(os.path.abspath(__file__))
1332
1550
  if args.mode == "apply" and not _registry_present(args.root):
1333
- return 0 # nothing registered: every account is already "reconciled"
1551
+ # Nothing registered: every account is already "reconciled" — unless one holds a
1552
+ # server to adopt (the first this pool ever had, added where the mirror never saw).
1553
+ dirs = args.account_dir if args.account_dir and not args.all \
1554
+ else manifest_account_dirs(args.root)
1555
+ try:
1556
+ if not adopt(args.root, args.provider, dirs, dry=True):
1557
+ return 0
1558
+ except Exception as exc: # noqa: BLE001 — the old no-op, never a failure
1559
+ _warn(f"adopt: {type(exc).__name__}: {exc}")
1560
+ return 0
1334
1561
  if args.mode == "list":
1335
1562
  return cmd_list(args)
1336
1563
  if args.mode == "add":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.59",
3
+ "version": "2.0.61",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,7 +58,7 @@
58
58
  "test": "npm run test:core && npm run test:seeding && npm run test:commands",
59
59
  "test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
60
60
  "test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
61
- "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && python3 tests/test_mcp_oauth.py && npm run test:codex-settings",
61
+ "test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-settings",
62
62
  "test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
63
63
  "test:commands": "python3 tests/test_packaged_commands.py"
64
64
  },