claude-multiacc 2.0.59 → 2.0.60

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 (101) hide show
  1. package/README.md +14 -1
  2. package/bin/claude +66 -10
  3. package/bin/claude-accounts +3 -0
  4. package/bin/codex +54 -5
  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_oauth.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  40. package/lib/common.sh +15 -0
  41. package/lib/mcp_registry.py +230 -8
  42. package/package.json +1 -1
  43. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  100. package/tests/run-tests.sh +143 -0
  101. package/tests/test_mcp_registry.py +234 -3
package/README.md CHANGED
@@ -568,7 +568,20 @@ claude-accounts mcp apply # re-apply now (repair drift)
568
568
  outcome into the registry and every other account (`learn`). This includes a command run
569
569
  from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
570
570
  an agent): the session's config dir is a pool account, so the same mirror applies. A
571
- config dir outside the pool is plain passthrough.
571
+ config dir outside the pool is plain passthrough. Global options in front of `mcp` are
572
+ looked past the way the real parser does (`alias claude='claude
573
+ --dangerously-skip-permissions'` puts one before every typed `claude mcp add`; until
574
+ 2026-10-22 that kept motionsites in 2 of 11 accounts).
575
+ - **Adoption**: whatever reached an account by another route — the real binary run
576
+ directly, a hand edit, another tool's installer — is adopted by the next reconcile: a
577
+ server (user scope or a project entry) or a project's `.mcp.json` approval that no section
578
+ names, live or retired, joins the registry (a replica: its overlay) and so every account.
579
+ Several definitions of one name: the one most accounts hold wins, then the newest config
580
+ file. Every limits pass reconciles all accounts this way, so an idle account catches up too.
581
+ Never adopted: anything the pool itself once wrote into that account (`<acct>/.mcp-managed.json`
582
+ — so a stray synced entry dropped without a tombstone, or a vanished overlay owner's
583
+ server, stays out of the synced registry), tombstoned names, and nothing at all with
584
+ `CLAUDE_MULTIACC_MCP_ADOPT=0` / `CODEX_MULTIACC_MCP_ADOPT=0`.
572
585
  - **Runner-managed servers**: on app-robot fleet Macs the runner publishes `appinspire-mcp` into
573
586
  each Mac's machine-local overlay with that Mac's own paths. Never `mcp add` it to the synced
574
587
  registry (directly, through a mirrored `claude|codex mcp add`, or via `appinspire-mcp install`):
package/bin/claude CHANGED
@@ -167,14 +167,71 @@ mcp_oauth_ensure() { # $1 acct dir — silent, never changes the exit status
167
167
  # account against a snapshot taken just before). The real CLI's own parser and writer do
168
168
  # the work; the shim only copies the outcome. Exits with the real binary's status; a
169
169
  # mirror failure is one hint line. Returns 1 when this run is not such a command.
170
+ # The index of the first positional argument as the real CLI's parser (commander) finds
171
+ # it: global options may come first, in any number. The operator's `alias claude='claude
172
+ # --dangerously-skip-permissions'` puts one in front of every `claude mcp add` typed at a
173
+ # prompt, and the scoped-limit fallback prepends `--model X`; the mirror once looked at $1
174
+ # alone, so on 2026-10-22 motionsites (and playwright, inspo) sat in the one account a
175
+ # typed add ran under. A value option skips its value; an optional-value one the next
176
+ # word unless it is an option; a variadic one every word up to the next option. `--`, or
177
+ # a positional after `--` counts (commander's own rule). No positional: status 1. Options
178
+ # of claude 2.1.308 (`claude --help`; hidden ones left out on purpose): an unknown one
179
+ # counts as a flag — at worst its value is taken for the positional, which is then no
180
+ # `mcp`, the run is not mirrored, and the registry's adoption picks the server up instead.
181
+ CLI_VALUE_OPTS=" --agent --agents --append-system-prompt --append-system-prompt-file \
182
+ --autocompact --debug-file --effort --environment --fallback-model --input-format \
183
+ --json-schema --max-budget-usd --model -n --name --output-format --permission-mode \
184
+ --permission-prompts --permission-prompt-tool --plugin-dir --plugin-url \
185
+ --remote-control-session-name-prefix --session-id --setting-sources --settings \
186
+ --system-prompt --system-prompt-file --system-prompt-snapshot "
187
+ CLI_OPTIONAL_OPTS=" -d --debug --cloud --from-pr --prompt-suggestions --remote-control --rc \
188
+ --remote -r --resume --teleport -w --worktree "
189
+ CLI_VARIADIC_OPTS=" --add-dir --allowedTools --allowed-tools --betas --disallowedTools \
190
+ --disallowed-tools --file --mcp-config --tools "
191
+ cli_positional_at() { # argv -> prints the index of its first positional argument
192
+ local i=1 a nx
193
+ while [ "$i" -le "$#" ]; do
194
+ a="${!i}"
195
+ case "$a" in
196
+ --) i=$((i + 1)); [ "$i" -le "$#" ] || return 1; printf '%s\n' "$i"; return 0 ;;
197
+ --*=*) i=$((i + 1)); continue ;;
198
+ -?*) ;;
199
+ *) printf '%s\n' "$i"; return 0 ;;
200
+ esac
201
+ i=$((i + 1))
202
+ case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
203
+ case "$CLI_OPTIONAL_OPTS" in
204
+ *" $a "*) nx="${!i:-}"; case "$nx" in ''|-*) ;; *) i=$((i + 1)) ;; esac; continue ;;
205
+ esac
206
+ case "$CLI_VARIADIC_OPTS" in
207
+ *" $a "*)
208
+ while [ "$i" -le "$#" ]; do
209
+ nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
210
+ done ;;
211
+ esac
212
+ done
213
+ return 1
214
+ }
215
+
216
+ # Whether argv is `claude [global options] mcp add|add-json|add-from-claude-desktop|remove …`.
217
+ # The exact word -p/--print (or --bg/--background) anywhere before a `--` puts the client in
218
+ # print/background mode, which never runs the subcommand.
219
+ mcp_mutation_argv() {
220
+ local i j a
221
+ for a in "$@"; do
222
+ case "$a" in --) break ;; -p|--print|--bg|--background) return 1 ;; esac
223
+ done
224
+ i="$(cli_positional_at "$@")" || return 1
225
+ [ "${!i}" = "mcp" ] || return 1
226
+ j=$((i + 1))
227
+ case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) return 0 ;; esac
228
+ return 1
229
+ }
230
+
170
231
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
171
- local d="$1" snap="" rc verb="${2:-}" sub="${3:-}"
232
+ local d="$1" snap="" rc
172
233
  shift
173
- # The scoped-limit fallback may have prepended `--model X` (a global option the real
174
- # CLI accepts in front of any subcommand): look past it.
175
- if [ "$verb" = "--model" ]; then verb="${3:-}"; sub="${4:-}"; fi
176
- [ "$verb" = "mcp" ] || return 1
177
- case "$sub" in add|add-json|remove) ;; *) return 1 ;; esac
234
+ mcp_mutation_argv "$@" || return 1
178
235
  [ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
179
236
  mcp_ensure "$d"
180
237
  snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
@@ -207,9 +264,8 @@ mcp_nested_mirror() {
207
264
  [ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] && return 1
208
265
  [ "${CLAUDE_MULTIACC_MCP:-1}" = "0" ] && return 1
209
266
  [ -n "${CLAUDE_CONFIG_DIR:-}" ] || return 1
210
- local verb="${1:-}" cfg="${CLAUDE_CONFIG_DIR%/}" d=""
211
- if [ "$verb" = "--model" ]; then verb="${3:-}"; fi
212
- [ "$verb" = "mcp" ] || return 1
267
+ local cfg="${CLAUDE_CONFIG_DIR%/}" d=""
268
+ mcp_mutation_argv "$@" || return 1
213
269
  # A DIRECT child of the pool root, by its given spelling or its physical one (an
214
270
  # adopted account is a symlink whose target is the operator's ~/.claude).
215
271
  case "$cfg" in
@@ -2522,7 +2578,7 @@ export CLAUDE_SHIM_ACTIVE=1
2522
2578
 
2523
2579
  # A `claude mcp add|add-json|remove` is run as a child under the picked account and
2524
2580
  # mirrored to every other one (exits inside); anything else falls through to exec.
2525
- if [ "${1:-}" = "mcp" ] || { [ "${1:-}" = "--model" ] && [ "${3:-}" = "mcp" ]; }; then
2581
+ if mcp_mutation_argv "$@"; then
2526
2582
  export CLAUDE_CONFIG_DIR="$pick"
2527
2583
  tok="$PICK_TOKEN"
2528
2584
  [ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
@@ -2963,6 +2963,9 @@ PYEOF
2963
2963
  # lock while a consumer reads its output.
2964
2964
  limits_lock_release
2965
2965
  trap - EXIT
2966
+ # Every account's MCP servers, adopting what one holds that the registry does not know
2967
+ # (lib/common.sh) — before the push below, which carries the registry to the peers.
2968
+ mcp_registry_pass
2966
2969
  # Whoever just refreshed is the only machine that CAN — pass it on before the
2967
2970
  # report, so a blind peer stops picking drained accounts within one cycle.
2968
2971
  limits_distribute
package/bin/codex CHANGED
@@ -119,11 +119,60 @@ mcp_ensure() { # $1 acct dir — silent, never changes the exit status
119
119
  # snapshot taken just before). The real CLI's own parser and writer do the work; the shim
120
120
  # only copies the outcome. Exits with the real binary's status; a mirror failure is one
121
121
  # hint line. Returns 1 when this run is not such a command.
122
+ # The index of the first positional argument as the real CLI's parser (clap) finds it:
123
+ # global options may come first, in any number. The operator's `alias codex='codex
124
+ # --dangerously-bypass-approvals-and-sandbox'` puts one in front of every `codex mcp add`
125
+ # typed at a prompt, and the mirror once looked at $1 alone (bin/claude met the same on
126
+ # 2026-10-22). A value option skips its value (attached `-mX`/`--model=X` is one word); a
127
+ # variadic one every word up to the next option. `--`, or no positional: status 1.
128
+ # Options of codex-cli 0.163 (`codex --help`); an unknown one counts as a flag.
129
+ CLI_VALUE_OPTS=" -c --config --enable --disable --remote --remote-auth-token-env -m --model \
130
+ --local-provider -p --profile -s --sandbox -C --cd --add-dir -a --ask-for-approval "
131
+ CLI_VARIADIC_OPTS=" -i --image "
132
+ cli_positional_at() { # argv -> prints the index of its first positional argument
133
+ local i=1 a nx
134
+ while [ "$i" -le "$#" ]; do
135
+ a="${!i}"
136
+ case "$a" in
137
+ --) return 1 ;;
138
+ --*=*) i=$((i + 1)); continue ;;
139
+ -?*) ;;
140
+ *) printf '%s\n' "$i"; return 0 ;;
141
+ esac
142
+ i=$((i + 1))
143
+ case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
144
+ case "$CLI_VARIADIC_OPTS" in
145
+ *" $a "*)
146
+ while [ "$i" -le "$#" ]; do
147
+ nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
148
+ done ;;
149
+ esac
150
+ done
151
+ return 1
152
+ }
153
+
154
+ # Whether argv is `codex [global options] mcp add|remove …`.
155
+ mcp_mutation_argv() {
156
+ local i j
157
+ i="$(cli_positional_at "$@")" || return 1
158
+ [ "${!i}" = "mcp" ] || return 1
159
+ j=$((i + 1))
160
+ # `mcp` takes options of its own before its subcommand: -c/--config, --enable, --disable
161
+ while :; do
162
+ case "${!j:-}" in
163
+ -c|--config|--enable|--disable) j=$((j + 2)) ;;
164
+ --*=*|-c?*) j=$((j + 1)) ;;
165
+ *) break ;;
166
+ esac
167
+ done
168
+ case "${!j:-}" in add|remove) return 0 ;; esac
169
+ return 1
170
+ }
171
+
122
172
  mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
123
173
  local d="$1" snap="" rc
124
174
  shift
125
- [ "${1:-}" = "mcp" ] || return 1
126
- case "${2:-}" in add|remove) ;; *) return 1 ;; esac
175
+ mcp_mutation_argv "$@" || return 1
127
176
  [ "${CODEX_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
128
177
  mcp_ensure "$d"
129
178
  snap="$(mktemp "${TMPDIR:-/tmp}/codex-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
@@ -156,8 +205,8 @@ mcp_nested_mirror() {
156
205
  [ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] && return 1
157
206
  [ "${CODEX_MULTIACC_MCP:-1}" = "0" ] && return 1
158
207
  [ -n "${CODEX_HOME:-}" ] || return 1
159
- local verb="${1:-}" cfg="${CODEX_HOME%/}" d=""
160
- [ "$verb" = "mcp" ] || return 1
208
+ local cfg="${CODEX_HOME%/}" d=""
209
+ mcp_mutation_argv "$@" || return 1
161
210
  # A DIRECT child of the pool root, by its given spelling or its physical one (an
162
211
  # adopted account is a symlink whose target is the operator's ~/.claude).
163
212
  case "$cfg" in
@@ -1526,7 +1575,7 @@ export CODEX_SHIM_ACTIVE=1
1526
1575
 
1527
1576
  # A `codex mcp add|remove` is run as a child under the picked account and mirrored to
1528
1577
  # every other one (exits inside); anything else falls through to exec.
1529
- if [ "${1:-}" = "mcp" ]; then
1578
+ if mcp_mutation_argv "$@"; then
1530
1579
  share_state_index "$pick"
1531
1580
  export CODEX_HOME="$pick"
1532
1581
  mcp_mirror_run "$pick" "$@" || true
@@ -1846,6 +1846,8 @@ PYEOF
1846
1846
  # lock while a consumer reads its output.
1847
1847
  codex_limits_lock_release
1848
1848
  trap - EXIT
1849
+ # Every account's MCP servers, adopting what one holds that the registry does not know.
1850
+ mcp_registry_pass
1849
1851
  # This pool's model catalog, once its copy is an hour old (lib/common.sh).
1850
1852
  model_catalog_backstop
1851
1853
  # The refresher fails open per account; a NON-zero status means the pass itself
package/lib/common.sh CHANGED
@@ -771,6 +771,21 @@ seed_mcp_registry() { # $1 = acct dir
771
771
  return 0
772
772
  }
773
773
 
774
+ # Once per scheduled limits pass: reconcile EVERY account, which first adopts what any
775
+ # one of them holds that the registry does not know (lib/mcp_registry.py adopt) — the
776
+ # backstop for a server added where the shims' mirror never saw it, reaching accounts no
777
+ # session launched on since. Unlike the shims' pre-exec apply it also runs before this
778
+ # pool has a registry at all. Inline, quiet, fail-open: a pass never dies on it.
779
+ mcp_registry_pass() {
780
+ local off
781
+ if [ "$MULTIACC_PROVIDER" = "codex" ]; then off="${CODEX_MULTIACC_MCP:-1}"; else off="${CLAUDE_MULTIACC_MCP:-1}"; fi
782
+ [ "$off" = "0" ] && return 0
783
+ [ -f "$LIB_DIR/mcp_registry.py" ] || return 0
784
+ "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" apply \
785
+ --all --fail-open --quiet >/dev/null 2>&1 </dev/null || true
786
+ return 0
787
+ }
788
+
774
789
  seed_account_dir() { # $1 = acct dir
775
790
  local d="$1"
776
791
  mkdir -p "$d"
@@ -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
 
@@ -1251,8 +1460,12 @@ def cmd_import_local(args):
1251
1460
  # it: an owner that stops shipping a server means it, and every account
1252
1461
  # would otherwise keep the stale copy for ever.
1253
1462
  dropped = [name for name in previous["mcpServers"] if name not in servers]
1463
+ # ...and stays retired until it publishes the name again: an account still holding
1464
+ # a copy must never have it adopted into the synced registry (adopt) once the next
1465
+ # import has forgotten the drop.
1466
+ kept = [name for name in previous["retired"] if name not in servers]
1254
1467
  section = _normalize_section({"mcpServers": servers,
1255
- "retired": sorted(set(retired) | set(dropped))})
1468
+ "retired": sorted(set(retired) | set(dropped) | set(kept))})
1256
1469
  if previous == section and owner in overlay["owners"]:
1257
1470
  changed = False
1258
1471
  else:
@@ -1330,7 +1543,16 @@ def main(argv=None):
1330
1543
  from codex_python import require_python311
1331
1544
  require_python311(os.path.abspath(__file__))
1332
1545
  if args.mode == "apply" and not _registry_present(args.root):
1333
- return 0 # nothing registered: every account is already "reconciled"
1546
+ # Nothing registered: every account is already "reconciled" — unless one holds a
1547
+ # server to adopt (the first this pool ever had, added where the mirror never saw).
1548
+ dirs = args.account_dir if args.account_dir and not args.all \
1549
+ else manifest_account_dirs(args.root)
1550
+ try:
1551
+ if not adopt(args.root, args.provider, dirs, dry=True):
1552
+ return 0
1553
+ except Exception as exc: # noqa: BLE001 — the old no-op, never a failure
1554
+ _warn(f"adopt: {type(exc).__name__}: {exc}")
1555
+ return 0
1334
1556
  if args.mode == "list":
1335
1557
  return cmd_list(args)
1336
1558
  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.60",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {