claude-multiacc 2.0.57 → 2.0.59

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 (150) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +30 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +82 -10
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +111 -1
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_origin.py +70 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +67 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_packaged_commands.py +41 -0
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # claude-accounts — manage the claude-multiacc account pool.
3
3
  # Subcommands: list status add import remove mint sync verify limits post-sync health
4
- # expired relogin export-credential import-credential
4
+ # expired relogin export-credential import-credential model-catalog
5
5
  set -u
6
6
  # lib/audit.py is imported by several subcommands; keep the install tree free of
7
7
  # __pycache__ (it may be root-owned, read-only, or an npm global prefix).
@@ -97,7 +97,14 @@ USAGE
97
97
  Skips accounts fetched in the last 45s and honors 429/refresh backoff;
98
98
  --force ignores all three.
99
99
  claude-accounts health limits + full verify + shim-on-PATH probe; logs to health.log
100
- claude-accounts self-update update this npm/git install; logs to update.log
100
+ claude-accounts self-update update this npm/git install (never to an older version); logs to update.log
101
+ claude-accounts model-catalog [refresh|show] [--if-stale] [--quiet]
102
+ the model/effort catalog auto-resume relaunches a session with. refresh (the default)
103
+ merges the catalog this release ships with the model caches the CLIs keep in their
104
+ account dirs (no request, no credential) into model-catalog.json in BOTH pool roots;
105
+ every install and self-update runs it, and each limits pass refreshes its own pool's
106
+ copy once it is an hour old. --if-stale leaves a younger copy alone; --quiet prints
107
+ nothing. show prints the merged catalog.
101
108
  claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
102
109
  claude-accounts mcp add <name> [--provider claude|codex|both] [--scope user|project]
103
110
  [--project PATH] [-e KEY=VAL]... [-H 'Header: v']...
@@ -123,6 +130,22 @@ USAGE
123
130
  re-apply the registry now (repair drift)
124
131
  claude-accounts mcp import-local --owner NAME (stdin JSON) machine-local overlay for
125
132
  a runner daemon; applied here, never synced
133
+ claude-accounts mcp auth-status [--json] MCP OAuth logins shared across THIS machine's
134
+ accounts: per server the pool's login (expiry, source account, refresh state, why
135
+ it is not shared), per account whether its file / Keychain item holds it (fresh,
136
+ stale, own, stub, missing, absent). A server authenticated once in ANY account
137
+ works in every account: the shim merges the newest login into the picked account's
138
+ stores before exec (a setup-token account with no .credentials.json gets one holding
139
+ the MCP logins alone; a Keychain item is never created), and every limits pass
140
+ refreshes it ahead of expiry and shares it into all of them. Never prints a token.
141
+ claude-accounts mcp auth-sync [--force] harvest + refresh what is due + share now, one
142
+ line per server (--force: retry a refresh
143
+ that is backing off)
144
+ claude-accounts mcp auth-forget <server-name>
145
+ log that server out of EVERY account on this machine. A /mcp "Clear
146
+ authentication", `claude mcp login` or re-authentication in ANY one account
147
+ revokes the shared login at the server too, so the pool retires it everywhere;
148
+ one sign-in in any account then shares the new login
126
149
 
127
150
  ENV
128
151
  CLAUDE_ACCOUNTS_ROOT pool root, overriding ~/.claude-accounts — one isolated pool
@@ -136,6 +159,12 @@ ENV
136
159
  CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
137
160
  CLAUDE_MULTIACC_AUTO_RESET=0 disable automatic limit-reset redemption
138
161
  CLAUDE_MULTIACC_MCP=0 disable the MCP registry (no reconcile at seed/launch, no mirror)
162
+ CLAUDE_MULTIACC_MCP_OAUTH=0 stop sharing MCP OAuth logins (no merge at launch, no
163
+ sync in the limits pass); CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0
164
+ never refreshes them — and so shares none: one shared login
165
+ needs one refresher
166
+ CLAUDE_MULTIACC_MODEL_CATALOG=0 never refresh the claude pool's model-catalog.json
167
+ (MULTIACC_MODEL_CATALOG=0: neither pool's)
139
168
  CLAUDE_MULTIACC_RESET_MIN_HORIZON a reset is held while every park lifts on
140
169
  its own within this many seconds (default 3600)
141
170
  EOF
@@ -432,6 +461,9 @@ for a in doc.get('accounts', []):
432
461
  print(f" oauth creds : unreadable ({e})")
433
462
  elif login['store'] == 'keychain':
434
463
  print(f" oauth creds : in the macOS Keychain — {login['reason']}")
464
+ elif login['reason']:
465
+ # e.g. a .credentials.json the client wrote with MCP server state only
466
+ print(f" oauth creds : none on this machine — {login['reason']}")
435
467
  else:
436
468
  print(" oauth creds : none on this machine")
437
469
  tpath = os.path.join(d, 'server.token')
@@ -706,6 +738,36 @@ add_cleanup_reserved() {
706
738
  mutate_unlock 2>/dev/null || true
707
739
  }
708
740
 
741
+ # Write the credentials document <src> INTO <dest>, the account's .credentials.json
742
+ # (0600, atomic): <src>'s top-level keys replace <dest>'s and every other key stays — the
743
+ # client keeps its MCP servers' OAuth state (mcpOAuth) in that same file. Fails, leaving
744
+ # <dest> as it was, when <src> is not a JSON object.
745
+ creds_merge_into() { # $1 = source document, $2 = destination .credentials.json
746
+ "$PYBIN" - "$1" "$2" <<'PYEOF'
747
+ import json, os, sys
748
+ src, dest = sys.argv[1], sys.argv[2]
749
+ try:
750
+ new = json.load(open(src))
751
+ except Exception:
752
+ sys.exit(1)
753
+ if not isinstance(new, dict):
754
+ sys.exit(1)
755
+ try:
756
+ cur = json.load(open(dest))
757
+ except Exception:
758
+ cur = None
759
+ doc = dict(cur) if isinstance(cur, dict) else {}
760
+ doc.update(new)
761
+ tmp = f'{dest}.tmp.{os.getpid()}'
762
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
763
+ with os.fdopen(fd, 'w') as f:
764
+ json.dump(doc, f)
765
+ f.flush()
766
+ os.fsync(f.fileno())
767
+ os.replace(tmp, dest)
768
+ PYEOF
769
+ }
770
+
709
771
  cmd_import() {
710
772
  require_manifest
711
773
  local email="${1:-}"
@@ -735,6 +797,10 @@ cmd_import() {
735
797
  # Validate ALL inputs before creating anything, so a bad token/creds path leaves no
736
798
  # half-made account dir behind.
737
799
  [ -n "$creds" ] && { [ -f "$creds" ] || die "credentials file not found: $creds"; }
800
+ # ...and a credentials file is a login only when it carries one: the client's MCP
801
+ # OAuth state alone would register an account with nothing to authenticate by.
802
+ [ -n "$creds" ] && { creds_file_has_login "$creds" \
803
+ || die "credentials file holds no Claude login (no claudeAiOauth): $creds"; }
738
804
  case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
739
805
  local tok=""
740
806
  if [ -n "$token_file" ]; then
@@ -756,11 +822,19 @@ cmd_import() {
756
822
  fi
757
823
  fi
758
824
  if [ -n "$creds" ]; then
759
- case "$mode" in
760
- copy) cp "$creds" "$d/.credentials.json" ;;
761
- move) mv "$creds" "$d/.credentials.json" ;;
762
- link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
763
- esac
825
+ if [ "$mode" != "link" ] && [ -s "$d/.credentials.json" ] && [ ! -L "$d/.credentials.json" ]; then
826
+ # The account's file may already hold its MCP servers' OAuth state: the imported
827
+ # login lands INTO it rather than signing every one of them out.
828
+ creds_merge_into "$creds" "$d/.credentials.json" \
829
+ || die "could not merge $creds into $d/.credentials.json — nothing imported"
830
+ [ "$mode" = "move" ] && rm -f "$creds"
831
+ else
832
+ case "$mode" in
833
+ copy) cp "$creds" "$d/.credentials.json" ;;
834
+ move) mv "$creds" "$d/.credentials.json" ;;
835
+ link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
836
+ esac
837
+ fi
764
838
  chmod 600 "$d/.credentials.json" 2>/dev/null || true
765
839
  fi
766
840
  if [ -n "$tok" ]; then
@@ -783,13 +857,14 @@ import json, os, re, sys
783
857
  manifest, root = sys.argv[1], sys.argv[2]
784
858
  sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
785
859
  import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
860
+ from audit import file_has_login # noqa: E402 (a login, not MCP state alone)
786
861
  accts = [a for a in json.load(open(manifest)).get('accounts', [])
787
862
  if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
788
863
  def has_auth(aid):
789
864
  d = os.path.join(root, aid)
790
865
  c = os.path.join(d, '.credentials.json')
791
866
  t = os.path.join(d, 'server.token')
792
- if (os.path.isfile(c) and os.path.getsize(c) > 0) or (os.path.isfile(t) and os.path.getsize(t) > 0):
867
+ if file_has_login(c) or (os.path.isfile(t) and os.path.getsize(t) > 0):
793
868
  return True
794
869
  # A login the client moved into the macOS Keychain still counts — deduping must
795
870
  # not throw away the one duplicate that actually holds the grant, even one filed
@@ -863,7 +938,7 @@ cmd_adopt() {
863
938
  valid_acct_id "$id" || die "not a valid account id: $id"
864
939
  account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
865
940
  local d="$ACC_ROOT/$id" default="$HOME/.claude"
866
- [ -f "$default/.credentials.json" ] || die "no default login at $default to adopt"
941
+ creds_file_has_login "$default/.credentials.json" || die "no default login at $default to adopt"
867
942
  # Seed the config-dir state file inside ~/.claude (CLAUDE_CONFIG_DIR mode reads
868
943
  # <dir>/.claude.json, while default mode uses ~/.claude.json at HOME level).
869
944
  if [ ! -f "$default/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
@@ -883,7 +958,11 @@ PYEOF
883
958
  return 0
884
959
  fi
885
960
  if [ -d "$d" ]; then
886
- [ -f "$d/.credentials.json" ] && die "$id already has its own credentials — refusing to replace with adopt"
961
+ # ANY credential refuses, not only a login: the rm below destroys what the dir holds,
962
+ # and the client keeps its MCP servers' OAuth grants in .credentials.json even beside
963
+ # no login at all (2.1.303) — while a portable token is a credential in its own right.
964
+ { [ -f "$d/.credentials.json" ] || [ -s "$d/server.token" ]; } \
965
+ && die "$id already has its own credentials — refusing to replace with adopt"
887
966
  rm -rf "${ACC_ROOT:?}/${id:?}"
888
967
  fi
889
968
  ln -s "$default" "$d"
@@ -1356,7 +1435,9 @@ cmd_login() {
1356
1435
  # as the wrong account (died above) must leave every item as it was.
1357
1436
  keychain_prune "$d"
1358
1437
  clear_auth_markers "$d" keep-token-park
1359
- if [ -s "$d/.credentials.json" ]; then
1438
+ # Where the login landed: the file only when it carries one — beside a Keychain
1439
+ # login it may hold the client's MCP server state alone.
1440
+ if creds_file_has_login "$d/.credentials.json"; then
1360
1441
  echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
1361
1442
  else
1362
1443
  echo "$id login saved (macOS Keychain, this machine, auto-refreshing — sessions without keychain access, e.g. ssh, cannot use it)."
@@ -1583,6 +1664,27 @@ os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
1583
1664
  return 0
1584
1665
  }
1585
1666
 
1667
+ # MCP OAuth logins (lib/mcp_oauth.py), once per pass: harvest every account's store,
1668
+ # refresh what is due (two hours ahead, or half a shorter lifetime) — the ONE refresher,
1669
+ # long before any client's own 5-minute window, because refresh tokens rotate — and share
1670
+ # the newest login into every account's existing stores. Its beat is what the shim's merge
1671
+ # shares behind, so a pool whose pass stops running stops spreading logins. Machine-local
1672
+ # (nothing here is pushed anywhere), so a replica runs it too. Detached the same way as
1673
+ # the telemetry push, so neither the pass nor a `--json` caller waits on a token
1674
+ # endpoint; the sync bounds itself and fails open.
1675
+ mcp_oauth_kick() {
1676
+ [ "${CLAUDE_MULTIACC_MCP_OAUTH:-1}" = "0" ] && return 0
1677
+ [ -f "$LIB_DIR/mcp_oauth.py" ] || return 0
1678
+ "$PYBIN" -c '
1679
+ import os, sys
1680
+ if os.fork():
1681
+ sys.exit(0)
1682
+ os.setsid()
1683
+ os.execv(sys.executable, [sys.executable] + sys.argv[1:])
1684
+ ' "$LIB_DIR/mcp_oauth.py" sync --root "$ACC_ROOT" --quiet >/dev/null 2>&1 </dev/null &
1685
+ return 0
1686
+ }
1687
+
1586
1688
  token_identities_check() {
1587
1689
  # A source may always use Keychain OAuth locally while its peers use setup tokens.
1588
1690
  # Prove those tokens before distributing telemetry, even if nobody launched them here.
@@ -1639,7 +1741,8 @@ limits_distribute_now() {
1639
1741
  done
1640
1742
  # The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
1641
1743
  # add` reaches every peer within one limits cadence, with no explicit sync. The
1642
- # machine-local overlay stays home (see sync_push_target).
1744
+ # machine-local overlay stays home (see sync_push_target), and so does mcp-oauth.json:
1745
+ # its MCP OAuth grants belong to this machine (lib/mcp_oauth.py).
1643
1746
  [ -f "$ACC_ROOT/mcp-servers.json" ] && printf 'mcp-servers.json\n' >> "$list"
1644
1747
  if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
1645
1748
 
@@ -1726,7 +1829,7 @@ cmd_limits() {
1726
1829
  import hashlib, json, os, sys, time, urllib.request
1727
1830
  sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
1728
1831
  import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
1729
- from audit import creds_doc_state, org_block_in_force # noqa: E402 (one rule for "can this credential work")
1832
+ from audit import creds_doc_state, file_has_login, org_block_in_force # noqa: E402 (one rule for "can this credential work")
1730
1833
  import claude_reset # noqa: E402 (automatic limit-reset redemption, the codex_reset twin)
1731
1834
 
1732
1835
  root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
@@ -1905,20 +2008,66 @@ def park_dead_grant(aid, d, slug, detail):
1905
2008
  say(f'{aid}: {detail} — re-login needed (claude-accounts relogin {aid})')
1906
2009
 
1907
2010
 
2011
+ # The client's own write lock: proper-lockfile's DIRECTORY <dir>/.storage-write.lock, stale
2012
+ # after 15 s (lib/mcp_oauth.py ClientLock). Claude Code takes it around every write of its
2013
+ # credential document, file or Keychain item alike — re-read, one change, the whole
2014
+ # document back — and its write FAILS after about 7.5 s of retries on a held lock. So the
2015
+ # refresher never holds it across the token POST: a lock held when a refresh would start
2016
+ # skips that refresh (nothing has rotated yet; the next pass retries), and the
2017
+ # check-and-set and the write take it after the POST, waiting longer, because a rotated
2018
+ # grant must land: past the wait (or with no helper) it is written as before, without it.
2019
+ try:
2020
+ from mcp_oauth import CLIENT_LOCK, CLIENT_LOCK_STALE_S, ClientLock # noqa: E402
2021
+ except Exception: # noqa: BLE001 — fail open: refresh exactly as before, unlocked
2022
+ ClientLock = None
2023
+ CLIENT_LOCK_PROBE_S = 1.0 # a client's write takes milliseconds: held this long, it is busy
2024
+ CLIENT_LOCK_WAIT_S = 10.0
2025
+
2026
+
2027
+ def client_lock_busy(d):
2028
+ """A live writer holds <d>'s client lock, and still does CLIENT_LOCK_PROBE_S later."""
2029
+ if ClientLock is None:
2030
+ return False
2031
+ path = os.path.join(d, CLIENT_LOCK)
2032
+ deadline = time.monotonic() + CLIENT_LOCK_PROBE_S
2033
+ while True:
2034
+ try:
2035
+ if time.time() - os.stat(path).st_mtime > CLIENT_LOCK_STALE_S:
2036
+ return False # its holder is gone; the write takes it over
2037
+ except OSError:
2038
+ return False # no lock: nobody is writing
2039
+ if time.monotonic() >= deadline:
2040
+ return True
2041
+ time.sleep(0.05)
2042
+
2043
+
1908
2044
  class FileStore:
1909
- """The OAuth credential as `.credentials.json`: atomic 0600 writes."""
2045
+ """The OAuth credential as `.credentials.json`: atomic 0600 writes. The client keeps
2046
+ its MCP servers' OAuth state (mcpOAuth, mcpOAuthClientConfig) in this same file, so
2047
+ a write lands INTO the document as it is at that moment: the keys it is given replace
2048
+ theirs, every other key stays (the last good read stands in for a file caught
2049
+ mid-write). Replacing it would sign every MCP server of the account out."""
1910
2050
  kind = 'file'
1911
2051
 
1912
2052
  def __init__(self, path):
1913
2053
  self.path = path
2054
+ self.base = None
1914
2055
 
1915
2056
  def read(self):
1916
- return json.load(open(self.path))
2057
+ doc = json.load(open(self.path))
2058
+ self.base = doc if isinstance(doc, dict) else None
2059
+ return doc
1917
2060
 
1918
2061
  def write(self, doc):
2062
+ try:
2063
+ cur = json.load(open(self.path))
2064
+ except Exception:
2065
+ cur = self.base
2066
+ merged = dict(cur) if isinstance(cur, dict) else {}
2067
+ merged.update(doc)
1919
2068
  fd = os.open(self.path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1920
2069
  with os.fdopen(fd, 'w') as f:
1921
- json.dump(doc, f)
2070
+ json.dump(merged, f)
1922
2071
  f.flush()
1923
2072
  os.fsync(f.fileno())
1924
2073
  os.replace(self.path + '.tmp', self.path)
@@ -1931,7 +2080,8 @@ class KeychainStore:
1931
2080
  back under the canonical name, which also drops the siblings holding the grant the
1932
2081
  refresh started from — NEVER copied out to a file: a file beside a Keychain item is
1933
2082
  a second copy of a ROTATING refresh grant, and the client reads the Keychain first,
1934
- so the two would drift apart and strand one."""
2083
+ so the two would drift apart and strand one. keychain.write lands INTO the item, so
2084
+ its MCP state stays as it is."""
1935
2085
  kind = 'keychain'
1936
2086
 
1937
2087
  def __init__(self, d, probe):
@@ -1955,19 +2105,35 @@ class KeychainStore:
1955
2105
  def oauth_store(d):
1956
2106
  """Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
1957
2107
  the string 'locked' (Keychain item exists but this session cannot open it), or
1958
- None. The file wins when both exist — same rule as lib/audit.oauth_login.
2108
+ None. The file wins when it holds a login — same rule as lib/audit.oauth_login; one
2109
+ holding only MCP state is no login store, and the Keychain is asked instead.
1959
2110
 
1960
2111
  A Keychain login found only under a name its client no longer reads (a USER-less
1961
2112
  sign-in's "unknown", lib/keychain.py) is moved under the canonical name here, once:
1962
2113
  until then the client, the shim and the audit all see no login."""
1963
2114
  cpath = os.path.join(d, '.credentials.json')
1964
- if os.path.isfile(cpath):
2115
+ if file_has_login(cpath):
1965
2116
  return FileStore(cpath)
1966
2117
  p = keychain.probe(d, legacy=True)
1967
2118
  if p['state'] == 'present':
1968
2119
  store = KeychainStore(d, p)
1969
2120
  if p['account'] != p['canonical']:
1970
- store.moved = keychain.write(d, p['doc'])
2121
+ # INTO the item the client reads: MCP state it already holds stays as it is;
2122
+ # the stranded item's own comes along only where that item has none. Under
2123
+ # the client's lock, like the refresher's write (a held one is waited on, then
2124
+ # the move goes ahead: until it lands no client can see this login at all).
2125
+ lock = ClientLock(d, CLIENT_LOCK_WAIT_S) if ClientLock is not None else None
2126
+ try:
2127
+ held = lock is not None and lock.acquire()
2128
+ except Exception: # noqa: BLE001
2129
+ held = False
2130
+ try:
2131
+ here = keychain.probe(d).get('doc') or {}
2132
+ store.moved = keychain.write(d, {k: v for k, v in p['doc'].items()
2133
+ if k == 'claudeAiOauth' or k not in here})
2134
+ finally:
2135
+ if held:
2136
+ lock.release()
1971
2137
  return store
1972
2138
  if p['state'] == 'locked':
1973
2139
  return 'locked'
@@ -1984,10 +2150,11 @@ def refresh_oauth(aid, d, store):
1984
2150
  spath = os.path.join(d, '.oauth-refresh.json')
1985
2151
  try:
1986
2152
  doc = store.read()
1987
- o = doc.get('claudeAiOauth', {})
2153
+ o = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
1988
2154
  # Present-but-null/non-object claudeAiOauth (interrupted or reset credential
1989
- # write) must degrade THIS account only, like every other malformed input.
1990
- if not isinstance(doc, dict) or not isinstance(o, dict):
2155
+ # write) must degrade THIS account only, like every other malformed input; and a
2156
+ # document with no login in it at all (MCP state alone) has nothing to refresh.
2157
+ if not isinstance(o, dict):
1991
2158
  return None
1992
2159
  except Exception:
1993
2160
  return None
@@ -2026,6 +2193,12 @@ def refresh_oauth(aid, d, store):
2026
2193
  pass
2027
2194
  say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
2028
2195
 
2196
+ if client_lock_busy(d):
2197
+ # The client is writing this credential right now (it may be its own refresh of
2198
+ # the same grant): no request is sent, so nothing rotates, and no backoff is noted.
2199
+ say(f'{aid}: oauth refresh skipped — the client holds its credential lock '
2200
+ f'({CLIENT_LOCK}); the next pass retries')
2201
+ return None
2029
2202
  started_with = o['refreshToken']
2030
2203
  body = json.dumps({'grant_type': 'refresh_token',
2031
2204
  'refresh_token': o['refreshToken'],
@@ -2086,25 +2259,39 @@ def refresh_oauth(aid, d, store):
2086
2259
  o['expiresAt'] = int((now + 3600) * 1000)
2087
2260
  if data.get('refresh_token_expires_in'):
2088
2261
  o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
2089
- doc['claudeAiOauth'] = o
2090
2262
  # CHECK-AND-SET. The grant rotates, and a live claude session refreshes the same
2091
2263
  # file. REFRESH_MIN_EXPIRED makes that unlikely, not impossible — and losing the
2092
2264
  # race by overwriting means the session's newer credential is destroyed. If the
2093
2265
  # on-disk refresh token is no longer the one this grant was issued against, the
2094
- # other writer won: keep its result, discard ours.
2266
+ # other writer won: keep its result, discard ours. Both under the client's own lock,
2267
+ # so no client write lands between the check and the write.
2268
+ lock = ClientLock(d, CLIENT_LOCK_WAIT_S) if ClientLock is not None else None
2095
2269
  try:
2096
- disk = store.read().get('claudeAiOauth', {})
2097
- if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
2098
- say(f'{aid}: credential was refreshed by something else mid-flight — '
2099
- f'keeping the newer one in the {store.kind}')
2100
- return None
2101
- except Exception:
2102
- pass
2270
+ held = lock is not None and lock.acquire()
2271
+ except Exception: # noqa: BLE001 — the rotated grant lands either way
2272
+ held = False
2273
+ if lock is not None and not held and os.path.isdir(lock.path):
2274
+ say(f'{aid}: the client kept its credential lock ({CLIENT_LOCK}) past '
2275
+ f'{int(CLIENT_LOCK_WAIT_S)}s — writing the rotated login without it')
2103
2276
  try:
2104
- store.write(doc)
2105
- except Exception as e:
2106
- say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
2107
- return None
2277
+ try:
2278
+ disk = store.read().get('claudeAiOauth', {})
2279
+ if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
2280
+ say(f'{aid}: credential was refreshed by something else mid-flight — '
2281
+ f'keeping the newer one in the {store.kind}')
2282
+ return None
2283
+ except Exception:
2284
+ pass
2285
+ try:
2286
+ # Only the login is ours: both stores write it INTO the document as it is now,
2287
+ # so the MCP state the client saved meanwhile (mcpOAuth) is never rolled back.
2288
+ store.write({'claudeAiOauth': o})
2289
+ except Exception as e:
2290
+ say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
2291
+ return None
2292
+ finally:
2293
+ if held:
2294
+ lock.release()
2108
2295
  try:
2109
2296
  os.remove(spath)
2110
2297
  except OSError:
@@ -2779,6 +2966,10 @@ PYEOF
2779
2966
  # Whoever just refreshed is the only machine that CAN — pass it on before the
2780
2967
  # report, so a blind peer stops picking drained accounts within one cycle.
2781
2968
  limits_distribute
2969
+ # This machine's MCP OAuth logins: harvested, refreshed when due, shared (detached).
2970
+ mcp_oauth_kick
2971
+ # This pool's model catalog, once its copy is an hour old (lib/common.sh).
2972
+ model_catalog_backstop
2782
2973
  # The refresher fails open per account; a NON-zero status means the pass itself
2783
2974
  # broke (unreadable manifest, dead python). Report the state anyway — stale data
2784
2975
  # beats silence — but hand the caller the failure, exactly as before --json existed.
@@ -2802,7 +2993,7 @@ import hashlib, json, os, re, subprocess, sys, time
2802
2993
  root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
2803
2994
  sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
2804
2995
  from audit import (audit_account, creds_state, creds_doc_state, # noqa: E402 (shared with the shim's rule)
2805
- marker_slug, org_block_in_force)
2996
+ file_has_login, marker_slug, org_block_in_force)
2806
2997
  import keychain # noqa: E402
2807
2998
  from claude_token_identity import check_identity # noqa: E402
2808
2999
  from claude_reset import cli_version # noqa: E402
@@ -2933,7 +3124,10 @@ for acct in manifest.get('accounts', []):
2933
3124
  d = os.path.join(root, aid)
2934
3125
  cpath = os.path.join(d, '.credentials.json')
2935
3126
  tpath = os.path.join(d, 'server.token')
2936
- has_creds = os.path.isfile(cpath)
3127
+ # A login, not merely a file: one holding the client's MCP state alone is no
3128
+ # credential to test — testing "it" ran the client with nothing to authenticate by,
3129
+ # and its "Not logged in" parked the account (the shim's file_login, 2026-10-19).
3130
+ has_creds = file_has_login(cpath)
2937
3131
  has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
2938
3132
  cred_doc = None
2939
3133
  cred_state = None
@@ -3087,7 +3281,9 @@ PYEOF
3087
3281
  # A target can be the Linux server (token-authenticated) or a peer Mac (which has
3088
3282
  # its own machine-local OAuth logins) — the pushed material is valid on both:
3089
3283
  # manifest, portable server.token files, seeds, and advisory limit state.
3090
- # Credentials (.credentials.json) are NEVER pushed anywhere.
3284
+ # Credentials (.credentials.json) are NEVER pushed anywhere — nor is the pool's
3285
+ # mcp-oauth.json, which holds this machine's MCP OAuth grants (lib/mcp_oauth.py): a
3286
+ # rotating refresh token used from two machines strands one of them.
3091
3287
  sync_push_target() {
3092
3288
  local server="$1" sroot="$2" srepo="$3"
3093
3289
  slog "push -> $server:$sroot"
@@ -3317,8 +3513,9 @@ cmd_mcp() {
3317
3513
  local args=()
3318
3514
  case "$sub" in
3319
3515
  add|add-json|remove|list|apply|import-local) shift ;;
3516
+ auth-status|auth-sync|auth-forget) shift; cmd_mcp_auth "$sub" "$@"; return $? ;;
3320
3517
  "") usage; return 1 ;;
3321
- *) die "unknown mcp subcommand: $sub (add, add-json, remove, list, apply, import-local)" ;;
3518
+ *) die "unknown mcp subcommand: $sub (add, add-json, remove, list, apply, import-local, auth-status, auth-sync, auth-forget)" ;;
3322
3519
  esac
3323
3520
  [ -f "$LIB_DIR/mcp_registry.py" ] || die "lib/mcp_registry.py is missing from this install"
3324
3521
  case "$sub" in
@@ -3389,6 +3586,24 @@ cmd_mcp() {
3389
3586
  return "$rc"
3390
3587
  }
3391
3588
 
3589
+ # MCP OAuth logins shared across THIS machine's accounts (lib/mcp_oauth.py) — the claude
3590
+ # pool alone, and never the sibling hop above: the store is <pool>/mcp-oauth.json of the
3591
+ # pool this CLI was pinned to, and nothing about it is synced.
3592
+ cmd_mcp_auth() { # $1 = auth-status|auth-sync|auth-forget, then its options
3593
+ local sub="$1"
3594
+ shift
3595
+ [ -f "$LIB_DIR/mcp_oauth.py" ] || die "lib/mcp_oauth.py is missing from this install"
3596
+ case "$sub" in
3597
+ auth-status)
3598
+ "$PYBIN" "$LIB_DIR/mcp_oauth.py" status --root "$ACC_ROOT" "$@" ;;
3599
+ auth-sync)
3600
+ "$PYBIN" "$LIB_DIR/mcp_oauth.py" sync --root "$ACC_ROOT" "$@" ;;
3601
+ auth-forget)
3602
+ [ $# -ge 1 ] || die "usage: $PROVIDER_CLI mcp auth-forget <server-name>"
3603
+ "$PYBIN" "$LIB_DIR/mcp_oauth.py" forget --root "$ACC_ROOT" "$@" ;;
3604
+ esac
3605
+ }
3606
+
3392
3607
  cmd_post_sync() {
3393
3608
  require_manifest
3394
3609
  local_pool_fixup
@@ -3442,7 +3657,21 @@ cmd_self_update() {
3442
3657
  ulog "self-update: already latest ($cur)"
3443
3658
  return 0
3444
3659
  fi
3445
- ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix, npm $npm_bin)"
3660
+ # Only ever UP, and only to a version known to be newer. A copy installed by hand ahead
3661
+ # of the registry (this Mac's builds, while npm publishing is down) must survive the
3662
+ # app-robot loop that calls this every 10 minutes: installing `latest` over it swapped
3663
+ # in a build that misreads state the newer one had already written (MCP-only
3664
+ # credential files: every setup-token account "Not logged in"). An unreadable registry
3665
+ # answer is no licence to install blind either.
3666
+ case "$lat" in [0-9]*.*[0-9]) ;; *)
3667
+ ulog "self-update: could not read the registry's version (npm view); not updating"
3668
+ return 1 ;;
3669
+ esac
3670
+ if ver_newer "$cur" "$lat"; then
3671
+ ulog "self-update: this install ($cur) is newer than the registry's $lat; not downgrading"
3672
+ return 0
3673
+ fi
3674
+ ulog "self-update: npm $cur -> $lat (prefix $prefix, npm $npm_bin)"
3446
3675
  if PATH="$npm_path" "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
3447
3676
  >>"$ACC_ROOT/update.log" 2>&1; then
3448
3677
  # Verify by re-reading the file on disk. npm reporting success says nothing
@@ -3453,6 +3682,7 @@ cmd_self_update() {
3453
3682
  return 1
3454
3683
  fi
3455
3684
  ulog "self-update: npm update ok ($after)"
3685
+ model_catalog_after_update "$quiet"
3456
3686
  else
3457
3687
  ulog "self-update: npm update FAILED (see update.log)"
3458
3688
  return 1
@@ -3504,7 +3734,7 @@ $shim_out"
3504
3734
  # distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
3505
3735
  # FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
3506
3736
  # the dispatcher below implements, and a test pins that both ways.
3507
- _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update mcp"
3737
+ _KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update mcp model-catalog"
3508
3738
  case " $_KNOWN_VERBS " in
3509
3739
  *" ${1:-help} "*)
3510
3740
  # ...but never past a literal `--`: from there on the words belong to an MCP
@@ -3537,6 +3767,7 @@ case "${1:-help}" in
3537
3767
  health) shift; cmd_health "$@" ;;
3538
3768
  self-update) shift; cmd_self_update "$@" ;;
3539
3769
  mcp) shift; cmd_mcp "$@" ;;
3770
+ model-catalog) shift; cmd_model_catalog "$@" ;;
3540
3771
  help|--help|-h) usage ;;
3541
3772
  *) usage; exit 1 ;;
3542
3773
  esac
package/bin/codex CHANGED
@@ -766,6 +766,7 @@ AR_AVOID_CLAUDE="" AR_AVOID_CODEX="" AR_POOL_CLAUDE="" AR_POOL_CODEX=""
766
766
  AR_ARGV=()
767
767
  AR_ORIG_ARGV=()
768
768
  AR_NO_DAEMON=0
769
+ AR_TRUST=0 # only a consumed automatic relaunch trusts its cwd in the new account
769
770
  AR_ROLLOUT_CURSOR=""
770
771
  AR_TIER="" # which cut produced the pick: eligible | soft | hard (probe answer)
771
772
  AR_R_SID="" # the session a relaunch file named, for the dead-token hint
@@ -987,11 +988,14 @@ ar_relaunch() { # $1 token
987
988
  # Field 2 is the word `autoresume`, never an account id: lib/report.py and the
988
989
  # *-accounts tools read an acct-NN in field 2 as a pick.
989
990
  sel_log "autoresume relaunch chain=${AR_CHAIN:--} from=${acct:--} class=${cls:--} depth=$AR_DEPTH"
991
+ AR_TRUST=1
990
992
  return 0
991
993
  }
992
994
 
993
995
  # The argv shapes a relaunch can rebuild as `resume <kept opts> <sid> <prompt>` — anything
994
996
  # else (exec/e, -p profile, -c overrides, any subcommand, an unknown flag) gets no watcher.
997
+ # The one -c a relaunch carries itself is let through: `-c model_reasoning_effort=<level>`,
998
+ # beside `-m`, pins the session's own effort (lib/autoresume.py codex_argv_parse mirrors it).
995
999
  # A positional must contain a space to count as a prompt: a single word may be a
996
1000
  # subcommand.
997
1001
  ar_argv_ok() { # the argv codex is about to get
@@ -1002,6 +1006,8 @@ ar_argv_ok() { # the argv codex is about to get
1002
1006
  sandbox:read-only) sandbox=1 ;;
1003
1007
  approval:on-request) approval=1 ;;
1004
1008
  model:*) case "$a" in ''|-*) return 1 ;; esac ;;
1009
+ effort:model_reasoning_effort=?*)
1010
+ case "${a#model_reasoning_effort=}" in *[!$AR_LOWER]*) return 1 ;; esac ;;
1005
1011
  *) return 1 ;;
1006
1012
  esac
1007
1013
  want=""
@@ -1013,6 +1019,7 @@ ar_argv_ok() { # the argv codex is about to get
1013
1019
  --sandbox) [ "$sandbox" = 0 ] || return 1; want=sandbox ;;
1014
1020
  --ask-for-approval) [ "$approval" = 0 ] || return 1; want=approval ;;
1015
1021
  -m|--model) want=model ;;
1022
+ -c) want=effort ;;
1016
1023
  --model=?*) ;;
1017
1024
  --last)
1018
1025
  [ "$resume" = 1 ] && [ "$sid" = 0 ] || return 1
@@ -1554,6 +1561,14 @@ fi
1554
1561
  share_state_index "$pick"
1555
1562
  if [ "$wants_retry" = "0" ]; then
1556
1563
  export CODEX_HOME="$pick"
1564
+ # Folder access is account-local even when the conversation and cwd are unchanged.
1565
+ # Persist the relaunch's trust before Codex starts, without sending keys into its TUI.
1566
+ if [ "$AR_TRUST" = "1" ]; then
1567
+ if ! ar_find_python || ! "$AR_PY" -I "$SELF_DIR/../lib/codex_trust.py" \
1568
+ --acct-dir "$pick" --cwd "$PWD" </dev/null >/dev/null 2>&1; then
1569
+ sel_log "autoresume trust-failed chain=${AR_CHAIN:--} acct=${pick##*/}"
1570
+ fi
1571
+ fi
1557
1572
  # Auto-resume: a gated, detached watcher beside this exec — the exec itself (same pid,
1558
1573
  # same environment minus the auto-resume variables). Supported clients own their
1559
1574
  # task through --no-daemon, including the continuation passed on resume.