claude-multiacc 2.0.58 → 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 (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -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 +81 -9
  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 +146 -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/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -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 +14 -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_mcp_registry.py +234 -3
  151. package/tests/test_packaged_commands.py +41 -0
@@ -0,0 +1,94 @@
1
+ """A claude client at critical memory, end to end: an isolated tmux server of its own, an
2
+ interactive shell, the production shim and watcher, and the cross-provider suite's disposable
3
+ fake TUI drawing the client's critical-memory banner in its footer -- and ignoring SIGTERM, as
4
+ the 8.3GB client of 2026-10-20 did. No provider CLI, credential or network."""
5
+
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import time
10
+ import unittest
11
+
12
+ from autoresume_cross_tmux_support import CrossScenario, TMUX
13
+
14
+
15
+ def teardown(case):
16
+ """Strict, whatever failed: the watchers told to stop, every fake TUI the case launched
17
+ killed (by pid, and only while its command line is still this case's), then the case's
18
+ tmux server and whatever is left of its watchers (CrossScenario.close)."""
19
+ try:
20
+ for pool in case.pools.values():
21
+ (pool / "autoresume.off").touch()
22
+ for row in case.records("launch"):
23
+ try:
24
+ args = subprocess.run(["ps", "-o", "args=", "-p", str(row["pid"])],
25
+ capture_output=True, text=True, timeout=5).stdout
26
+ except (OSError, subprocess.SubprocessError):
27
+ continue
28
+ if str(case.home) in args:
29
+ try:
30
+ os.kill(row["pid"], signal.SIGKILL)
31
+ except OSError:
32
+ pass
33
+ finally:
34
+ case.close()
35
+
36
+
37
+ def gone(pid):
38
+ """Neither running nor a zombie left unreaped."""
39
+ try:
40
+ os.kill(pid, 0)
41
+ except ProcessLookupError:
42
+ return True
43
+ state = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True,
44
+ text=True, timeout=5).stdout.strip()
45
+ return not state or state.startswith("Z")
46
+
47
+
48
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
49
+ class MemoryTmuxTests(unittest.TestCase):
50
+ def test_the_banner_restarts_the_client_in_place_even_one_deaf_to_sigterm(self):
51
+ # MEMORY_MIN_UPTIME 0 here (900 s for real) and a scan every 0.2 s
52
+ case = CrossScenario(FAKE_MEMORY_BANNER="claude", FAKE_IGNORE_TERM="claude",
53
+ CLAUDE_MULTIACC_AR_MEMORY_MIN_UPTIME="0",
54
+ CLAUDE_MULTIACC_AR_MEMORY_SCAN="0.2")
55
+ self.addCleanup(teardown, case)
56
+ case.start("claude")
57
+ case.wait(lambda: len(case.records("ready")) >= 2, "same session resumed", timeout=90)
58
+ case.wait(lambda: "autoresume switch" in case.logs("claude"), "watcher confirms switch",
59
+ timeout=30)
60
+ first, second = case.records("launch")[:2]
61
+ self.assertEqual([first["provider"], second["provider"]], ["claude", "claude"])
62
+ self.assertEqual(first["sid"], second["sid"])
63
+ self.assertEqual(second["argv"][second["argv"].index("--resume") + 1], first["sid"])
64
+ self.assertIn("because the previous process reached critical memory usage (8.3GB).",
65
+ second["argv"][-1])
66
+ log = case.logs("claude")
67
+ self.assertIn("class=memory code=critical_memory sid=%s acct=%s reset=- size=8.3GB "
68
+ "src=pane" % (first["sid"], first["account"]), log)
69
+ self.assertRegex(log, r"autoresume switch provider=claude chain=\w+ from=%s class=memory "
70
+ r"sid=%s depth=1 probe=acct-0[12] size=8\.3GB" % (first["account"],
71
+ first["sid"]))
72
+ self.assertRegex(log, r"autoresume relaunch chain=\w+ from=%s class=memory depth=1\n"
73
+ % first["account"])
74
+ # it took the TERM and stayed; the KILL after TERM_GRACE ended it, and the relaunch
75
+ # still came: typed into the shell, never a key into either TUI
76
+ self.assertEqual([row["signal"] for row in case.records("signal")
77
+ if row["pid"] == first["pid"]], [signal.SIGTERM])
78
+ self.assertTrue(gone(first["pid"]))
79
+ self.assertEqual(case.records("keys"), [], "no keystroke may reach either TUI")
80
+ for name in ("acct-01", "acct-02"):
81
+ for marker in (".limited", ".expired"):
82
+ self.assertFalse((case.pools["claude"] / name / marker).exists(), name + marker)
83
+ # the relaunched client's own footer has no banner: it is left running
84
+ self.assertEqual([row["banner"] for row in case.records("screen")], [True, False])
85
+ case.wait(lambda: case.logs("claude").count("autoresume watch") == 2, "second watcher",
86
+ timeout=30)
87
+ time.sleep(2)
88
+ self.assertEqual(case.logs("claude").count("autoresume switch"), 1)
89
+ self.assertEqual(len(case.records("launch")), 2)
90
+ self.assertFalse(gone(second["pid"]))
91
+
92
+
93
+ if __name__ == "__main__":
94
+ unittest.main()
@@ -38,7 +38,8 @@ class AutoresumeProbeTests(unittest.TestCase):
38
38
  root.mkdir()
39
39
  up = provider.upper()
40
40
  self.env[up + "_ACCOUNTS_ROOT"] = str(root)
41
- for key in ("KEYCHAIN", "MCP", "AUTORESUME", "AUTO_RESET", "CLIENT_LIMITS", "PATH_PROBE"):
41
+ for key in ("KEYCHAIN", "MCP", "MCP_OAUTH", "AUTORESUME", "AUTO_RESET", "CLIENT_LIMITS",
42
+ "PATH_PROBE"):
42
43
  self.env[up + "_MULTIACC_" + key] = "0"
43
44
  self.env[up + "_MULTIACC_NO_SYNC"] = "1"
44
45
  self.env[up + "_MULTIACC_NO_DISTRIBUTE"] = "1"
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env python3
2
+ """A same-provider relaunch runs with the model and effort the session itself last ran with.
3
+
4
+ The watcher reads them from the session's own history when it decides (lib/session_settings.py),
5
+ matches them through the pool's catalog (lib/model_match.py) and puts their flags in place of
6
+ the launch's stale model/effort options; codex_argv_parse and bin/codex's gate let exactly that
7
+ one ``-c`` override through. Sandboxed: temp pools and the fake runner of test_autoresume, plus
8
+ the production shims with fake TUIs in real tmux where tmux exists. Python 3.9+.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ import unittest
18
+ from unittest import mock
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+ import test_autoresume as fixtures # noqa: E402
22
+ from autoresume_cross_tmux_support import CrossScenario # noqa: E402
23
+ from autoresume_tmux_support import SID as TMUX_SID, TMUX, TmuxScenario # noqa: E402
24
+
25
+ ar = fixtures.ar
26
+ ROOT = Path(__file__).resolve().parents[1]
27
+ SID, SID2, CX_SID, CX_SID2 = fixtures.SID, fixtures.SID2, fixtures.CX_SID, fixtures.CX_SID2
28
+ EFFORT = "model_reasoning_effort="
29
+ QUOTA = {"type": "task_complete", "error": {"message": "You've hit your usage limit. Try again later.",
30
+ "codex_error_info": "usage_limit_exceeded"}}
31
+
32
+
33
+ def claude_reply(ts, model, effort=None, requested=None, **extra):
34
+ """A main-thread reply as claude 2.1.299 writes it: its model, the requested one, the effort."""
35
+ rec = fixtures.cc_reply(ts, **extra)
36
+ rec["message"]["model"] = model
37
+ if requested:
38
+ rec["requestedModel"] = requested
39
+ if effort:
40
+ rec["effort"] = rec["perTurnEffort"] = effort
41
+ return rec
42
+
43
+
44
+ def turn_context(ts, model, effort=None, cwd="/w"):
45
+ """A codex 0.162 turn_context (the fields the settings reader does not use trimmed)."""
46
+ payload = {"turn_id": "01a135db-f14d-7a60-b656-d5ff72abd71d", "cwd": cwd, "approval_policy": "never",
47
+ "sandbox_policy": {"type": "danger-full-access"}, "model": model, "summary": "none"}
48
+ if effort:
49
+ payload["effort"] = effort
50
+ return {"timestamp": fixtures.iso(ts), "ordinal": 4, "type": "turn_context", "payload": payload}
51
+
52
+
53
+ def settings_applied(ts, model, effort, thread=CX_SID):
54
+ return fixtures.cx_event(ts, {"type": "thread_settings_applied", "thread_id": thread,
55
+ "thread_settings": {"model": model, "reasoning_effort": effort}})
56
+
57
+
58
+ def write_overlay(root, provider, models):
59
+ """A pool's own catalog copy, fresher than the shipped one, so its models are the ones used."""
60
+ section = {"fetched_at": "2099-01-01T00:00:00Z", "source": "test", "models": models}
61
+ (root / "model-catalog.json").write_text(json.dumps({"schema": 1, provider: section}))
62
+
63
+
64
+ def pool(test, kind):
65
+ case = (fixtures.CodexWatcherTests if kind == "codex" else fixtures.PoolCase)(methodName="runTest")
66
+ case.setUp()
67
+ test.addCleanup(case.tearDown)
68
+ return case
69
+
70
+
71
+ def shell_gate():
72
+ """bin/codex's own ar_argv_ok with the helpers it calls, as one bash script."""
73
+ text = (ROOT / "bin" / "codex").read_text()
74
+ parts = [line for line in text.splitlines() if line.startswith(("AR_LOWER=", "AR_ALNUM=", "sess_id_ok() {"))]
75
+ for name in ("ar_uuid_ok", "ar_argv_ok"):
76
+ start = text.index("\n%s() {" % name) + 1
77
+ parts.append(text[start:text.index("\n}\n", start) + 2])
78
+ return "\n".join(parts) + '\nar_argv_ok "$@"\n'
79
+
80
+
81
+ class RelaunchArgvSettingsTests(unittest.TestCase):
82
+ def rl(self, provider, argv, settings=None):
83
+ sid = SID if provider == "claude" else CX_SID
84
+ return ar.relaunch_argv(provider, argv, sid, "quota", None, settings)
85
+
86
+ def test_claude_pins_the_effort_and_drops_a_launch_model_the_session_left(self):
87
+ p = ar.resume_prompt("quota")
88
+ pins = {"model": "claude-opus-5-5", "effort": "max"}
89
+ # a /model since the launch: the launch's --model goes, and --resume restores the session's own
90
+ self.assertEqual(self.rl("claude", ["--model", "sonnet", "--effort", "low", "fix the tests now"], pins),
91
+ ["--effort", "max", "--resume", SID, p])
92
+ self.assertEqual(self.rl("claude", ["--dangerously-skip-permissions", "--model=sonnet", "-r", SID2,
93
+ "--permission-mode", "plan"], pins),
94
+ ["--dangerously-skip-permissions", "--permission-mode", "plan",
95
+ "--effort", "max", "--resume", SID, p])
96
+ self.assertEqual(self.rl("claude", ["--model", "claude-opus-4-8"], pins),
97
+ ["--effort", "max", "--resume", SID, p])
98
+ dated = {"model": "claude-haiku-4-5-20251001", "effort": "low"}
99
+ self.assertEqual(self.rl("claude", ["--model", "claude-haiku-4-5"], dated),
100
+ ["--model", "claude-haiku-4-5", "--effort", "low", "--resume", SID, p])
101
+ # the model the session still runs is replayed as launched: [1m] lives only in that flag
102
+ for launch in ("claude-opus-5-5[1m]", "claude-opus-5-5[1M]", "opus[1m]", "opus", "best", "default"):
103
+ self.assertEqual(self.rl("claude", ["--model", launch], pins),
104
+ ["--model", launch, "--effort", "max", "--resume", SID, p])
105
+ # a value the history did not record keeps the launch's own option for it
106
+ self.assertEqual(self.rl("claude", ["--effort", "low", "--model", "sonnet"], {"model": "claude-fable-5-1"}),
107
+ ["--effort", "low", "--resume", SID, p])
108
+ self.assertEqual(self.rl("claude", ["--model", "sonnet"], {"model": None, "effort": "high"}),
109
+ ["--model", "sonnet", "--effort", "high", "--resume", SID, p])
110
+
111
+ def test_codex_pins_both_or_nothing_right_before_the_session(self):
112
+ p = ar.resume_prompt("quota")
113
+ pins = {"model": "gpt-6-astra", "effort": "ultra"}
114
+ self.assertEqual(self.rl("codex", ["--yolo", "-m", "gpt-5.5", "resume", CX_SID2], pins),
115
+ ["resume", "--yolo", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", CX_SID, p])
116
+ self.assertEqual(self.rl("codex", ["--model=gpt-5", "-c", EFFORT + "low", "--no-daemon", "fix it now"], pins),
117
+ ["resume", "--no-daemon", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", CX_SID, p])
118
+ self.assertEqual(self.rl("codex", ["--model", "gpt-5", "resume", "--last"], pins),
119
+ ["resume", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", CX_SID, p])
120
+ # a model with no effort to pin beside it: the launch's own -m/-c go too (one alone would
121
+ # drop the thread's other saved value), and Codex restores both from the thread
122
+ for launch in (["--model", "gpt-5"], ["-c", EFFORT + "high"], ["-m", "gpt-5", "-c", EFFORT + "low"]):
123
+ self.assertEqual(self.rl("codex", launch, {"model": "gpt-6-sol"}), ["resume", CX_SID, p])
124
+ # an effort without a model pins nothing, and the launch's options stay as given
125
+ self.assertEqual(self.rl("codex", ["--model", "gpt-5"], {"effort": "high"}),
126
+ ["resume", "--model", "gpt-5", CX_SID, p])
127
+
128
+ def test_no_or_unusable_settings_change_nothing(self):
129
+ argvs = {"claude": ["--dangerously-skip-permissions", "--model", "sonnet", "--effort", "low"],
130
+ "codex": ["--yolo", "-m", "gpt-5.5", "-c", EFFORT + "low"]}
131
+ for provider, argv in argvs.items():
132
+ plain = self.rl(provider, argv)
133
+ for settings in (None, {}, "opus", {"model": None, "effort": None}, {"model": "-p", "effort": "MAX"},
134
+ {"model": "claude opus", "effort": "x high"}, {"model": 5, "effort": ["max"]},
135
+ {"model": "a" * 201, "effort": "max\n"}):
136
+ with self.subTest(provider=provider, settings=settings):
137
+ self.assertEqual(self.rl(provider, argv, settings), plain)
138
+
139
+ def test_a_pinned_relaunch_is_itself_relaunchable(self):
140
+ p = ar.resume_prompt("quota")
141
+ first = self.rl("codex", ["--yolo"], {"model": "gpt-6-astra", "effort": "ultra"})
142
+ self.assertEqual(self.rl("codex", first, {"model": "gpt-6-sol", "effort": "max"}),
143
+ ["resume", "--yolo", "-m", "gpt-6-sol", "-c", EFFORT + "max", CX_SID, p])
144
+ self.assertEqual(self.rl("codex", first), first) # unknown next time: the last pins ride along
145
+ first = self.rl("claude", [], {"model": "claude-opus-5-5", "effort": "max"})
146
+ self.assertEqual(self.rl("claude", first, {"model": "claude-fable-5-1", "effort": "xhigh"}),
147
+ ["--effort", "xhigh", "--resume", SID, p])
148
+ self.assertEqual(self.rl("claude", first), first)
149
+
150
+ def test_argv_settings_reads_what_an_argv_pins(self):
151
+ self.assertEqual(ar.argv_settings("claude", ["--model=a", "--model", "b[1m]", "--effort", "max", "--resume",
152
+ SID, "go on now"]), {"model": "b[1m]", "effort": "max"})
153
+ self.assertEqual(ar.argv_settings("codex", ["resume", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", CX_SID,
154
+ "go on now"]), {"model": "gpt-6-astra", "effort": "ultra"})
155
+ self.assertEqual(ar.argv_settings("codex", ["--effort", "max", "-c", "model=o3", "x y"]), {})
156
+ self.assertEqual(ar.argv_settings("claude", ["-m", "x", "--model"]), {})
157
+
158
+
159
+ class CodexEffortOverrideTests(unittest.TestCase):
160
+ ACCEPTED = (["-c", EFFORT + "high"], ["--yolo", "-c", EFFORT + "xhigh", "fix the build now"],
161
+ ["resume", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", CX_SID, "keep going now"],
162
+ ["resume", CX_SID, "-c", EFFORT + "low", "-c", EFFORT + "max"], [], ["--yolo", "-m", "gpt-5.5"])
163
+ REFUSED = (["-c", "model=o3"], ["-c", EFFORT], ["-c", EFFORT + "High"], ["-c", EFFORT + "x-high"],
164
+ ["-c", EFFORT + "high now"], ["-c", EFFORT + "high\n"], ["-c", EFFORT + "h\u00efgh"],
165
+ ["-c", "sandbox_mode=danger-full-access"], ["-c"], ["-c", "-m"], ["--config", EFFORT + "high"],
166
+ ["-c" + EFFORT + "high"], ["-c", " " + EFFORT + "high"], ["exec", "do it now"])
167
+
168
+ def test_only_the_effort_override_is_accepted_and_kept(self):
169
+ self.assertEqual(ar.codex_argv_parse(["-c", EFFORT + "high", "resume", CX_SID]),
170
+ (["-c", EFFORT + "high"], CX_SID))
171
+ for argv in self.ACCEPTED:
172
+ self.assertIsNotNone(ar.codex_argv_parse(argv), argv)
173
+ for argv in self.REFUSED:
174
+ self.assertIsNone(ar.codex_argv_parse(argv), argv)
175
+ self.assertIsNone(ar.relaunch_argv("codex", argv, CX_SID, "quota"), argv)
176
+
177
+ def test_the_shims_gate_agrees_with_the_parser(self):
178
+ script = shell_gate()
179
+ env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LC_ALL": "C"}
180
+ for argv in self.ACCEPTED + self.REFUSED:
181
+ with self.subTest(argv=argv):
182
+ done = subprocess.run(["bash", "-c", script, "ar_argv_ok"] + list(argv), env=env,
183
+ capture_output=True, timeout=10)
184
+ self.assertEqual(done.stderr, b"")
185
+ self.assertEqual(done.returncode == 0, ar.codex_argv_parse(list(argv)) is not None)
186
+
187
+
188
+ class WatcherSettingsTests(unittest.TestCase):
189
+ def claude_quota(self, case, argv, records, env=None):
190
+ state = case.write_state(argv)
191
+ case.registry()
192
+ case.transcript(records + [fixtures.cc_quota(case.t0 - 1, resets=int(case.t0) + 7200)])
193
+ return case.watcher(state, env)
194
+
195
+ def test_claude_relaunch_pins_the_latest_main_thread_settings(self):
196
+ case = pool(self, "claude")
197
+ watcher, runner = self.claude_quota(case, ["--dangerously-skip-permissions", "--model", "sonnet",
198
+ "--effort", "low"], [
199
+ claude_reply(case.t0 - 30, "claude-sonnet-5-5", "low", "sonnet"),
200
+ claude_reply(case.t0 - 20, "claude-opus-5-5", "max", "claude-opus-5-5[1m]"),
201
+ claude_reply(case.t0 - 10, "claude-haiku-5-5", "low", isSidechain=True)])
202
+ self.assertEqual(watcher.run(), 0)
203
+ pinned = ["--dangerously-skip-permissions", "--effort", "max", "--resume", SID, ar.resume_prompt("quota")]
204
+ self.assertEqual(runner.probes[0]["argv"], [case.self_path] + pinned)
205
+ kv, argv = case.relaunch_of(runner)
206
+ self.assertEqual(argv, pinned)
207
+ # the model --resume restores rides in the relaunch file, for the shim's model-scoped fallback
208
+ self.assertEqual(kv["model"], "claude-opus-5-5[1m]")
209
+ line = case.log_lines()[-1]
210
+ self.assertEqual(line.split()[1:3], ["autoresume", "switch"])
211
+ self.assertTrue(line.endswith(" probe=acct-02 effort=max"), line)
212
+
213
+ def test_claude_effort_is_clamped_by_the_pools_own_catalog(self):
214
+ case = pool(self, "claude")
215
+ write_overlay(case.root, "claude", {"claude-test-9": {
216
+ "family": "opus", "version": [9], "efforts": ["low", "high"], "default_effort": "low", "listed": True}})
217
+ watcher, runner = self.claude_quota(case, [], [claude_reply(case.t0 - 20, "claude-test-9", "max")])
218
+ watcher.run()
219
+ self.assertEqual(case.relaunch_of(runner)[1][:2], ["--effort", "high"])
220
+
221
+ def test_codex_relaunch_pins_the_rollouts_latest_own_settings(self):
222
+ case = pool(self, "codex")
223
+ work = str(case.work)
224
+ rc, watcher, runner, _ = case.run_codex(["--yolo", "-m", "gpt-5.5", "resume", CX_SID], [
225
+ fixtures.cx_meta(case.launched - 600, cwd=work),
226
+ turn_context(case.launched - 500, "gpt-6-astra", "xhigh", work),
227
+ settings_applied(case.launched - 400, "gpt-6-astra", "ultra"),
228
+ settings_applied(case.launched - 300, "gpt-6-luna", "low", thread=CX_SID2),
229
+ fixtures.cx_error(case.t0 - 1, "usage_limit_exceeded")])
230
+ self.assertEqual(rc, 0)
231
+ pinned = ["resume", "--yolo", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", CX_SID, ar.resume_prompt("quota")]
232
+ self.assertEqual(runner.probes[0]["argv"], [case.self_path] + pinned)
233
+ self.assertEqual(case.relaunch_of(runner)[1], pinned)
234
+ self.assertTrue(case.log_lines()[-1].endswith(" probe=acct-02 model=gpt-6-astra effort=ultra"))
235
+
236
+ def test_codex_absent_effort_takes_the_catalog_default_or_pins_nothing(self):
237
+ prompt = ar.resume_prompt("quota")
238
+ for known, expected in ((True, ["-m", "gpt-test-nova", "-c", EFFORT + "high"]), (False, [])):
239
+ with self.subTest(known=known):
240
+ case = pool(self, "codex")
241
+ if known:
242
+ write_overlay(case.root, "codex", {"gpt-test-nova": {
243
+ "family": "sol", "version": [9], "efforts": ["low", "high"], "default_effort": "high",
244
+ "listed": True}})
245
+ rc, watcher, runner, _ = case.run_codex(["resume", CX_SID], [
246
+ fixtures.cx_meta(case.launched - 600, cwd=str(case.work)),
247
+ turn_context(case.launched - 500, "gpt-test-nova", None, str(case.work)),
248
+ fixtures.cx_error(case.t0 - 1, "usage_limit_exceeded")])
249
+ self.assertEqual(case.relaunch_of(runner)[1], ["resume"] + expected + [CX_SID, prompt])
250
+
251
+ def test_settings_are_read_when_deciding_never_per_tick(self):
252
+ case = pool(self, "claude")
253
+ watcher, runner = self.claude_quota(case, [], [claude_reply(case.t0 - 20, "claude-opus-5-5", "max")],
254
+ {"CLAUDE_MULTIACC_AR_HOLD_REPROBE": "5"})
255
+ runner.probe_out = "pick=acct-01 tier=soft\n"
256
+ registry = case.root / "acct-01" / "sessions" / ("%d.json" % case.PID)
257
+ runner.at(case.t0 + 16.5, lambda: (registry.unlink(), runner.exit(case.PID)))
258
+ with mock.patch.object(ar, "session_settings", wraps=ar.session_settings) as read:
259
+ self.assertEqual(watcher.run(), 0)
260
+ self.assertEqual((len(runner.probes), read.call_count), (3, 3))
261
+ for probe in runner.probes:
262
+ self.assertEqual(probe["argv"][1:3], ["--effort", "max"])
263
+
264
+ def test_a_settings_failure_relaunches_with_the_launchs_own_options(self):
265
+ for name in ("session_settings", "load_catalog", "resume_settings"):
266
+ with self.subTest(fails=name):
267
+ case = pool(self, "claude")
268
+ watcher, runner = self.claude_quota(case, ["--model", "sonnet"],
269
+ [claude_reply(case.t0 - 20, "claude-opus-5-5", "max")])
270
+ with mock.patch.object(ar, name, side_effect=RuntimeError("unreadable")):
271
+ self.assertEqual(watcher.run(), 0)
272
+ kv, argv = case.relaunch_of(runner)
273
+ self.assertEqual(argv, ["--model", "sonnet", "--resume", SID, ar.resume_prompt("quota")])
274
+ self.assertEqual("model" in kv, name != "session_settings")
275
+ self.assertEqual(case.events(), ["watch", "detect", "switch"])
276
+ self.assertTrue(case.log_lines()[-1].endswith(" probe=acct-02 model=sonnet"))
277
+
278
+
279
+ @unittest.skipUnless(TMUX, "real tmux is unavailable")
280
+ class RealTmuxSettingsTests(unittest.TestCase):
281
+ """The production shims take a pinned relaunch argv, and the resumed session is supervised again."""
282
+
283
+ def test_codex_resume_carries_the_rollouts_pins_and_keeps_a_watcher(self):
284
+ case = TmuxScenario()
285
+ self.addCleanup(case.close)
286
+ case.start(["--yolo", "-m", "gpt-5.5"])
287
+ with case.rollout.open("a") as target:
288
+ target.write(json.dumps(turn_context(time.time(), "gpt-6-astra", "ultra", str(case.work))) + "\n")
289
+ case.append(QUOTA)
290
+ case.wait(lambda: "autoresume switch" in case.log.read_text(), "pinned resume confirmed")
291
+ case.wait(lambda: case.log.read_text().count("autoresume watch") == 2, "resumed session supervised")
292
+ second = [arg for arg in case.records("launch")[1]["argv"] if arg != "--no-daemon"]
293
+ self.assertEqual(second[:-1], ["resume", "--yolo", "-m", "gpt-6-astra", "-c", EFFORT + "ultra", TMUX_SID])
294
+ switch = [line for line in case.log.read_text().splitlines() if " autoresume switch " in line]
295
+ self.assertTrue(switch[0].endswith(" model=gpt-6-astra effort=ultra"), switch)
296
+
297
+ def test_claude_resume_carries_the_transcripts_pins_and_keeps_a_watcher(self):
298
+ case = CrossScenario()
299
+ self.addCleanup(case.close)
300
+ case.start("claude", ["--dangerously-skip-permissions", "--model", "sonnet", "--effort", "low"])
301
+ ready = case.records("ready")[-1]
302
+ reply = claude_reply(time.time(), "claude-opus-5-5", "xhigh", "claude-opus-5-5[1m]",
303
+ sessionId=ready["sid"], cwd=str(case.work))
304
+ with Path(ready["path"]).open("a") as target:
305
+ target.write(json.dumps(reply) + "\n")
306
+ case.quota()
307
+ case.wait(lambda: "autoresume switch" in case.logs("claude"), "pinned resume confirmed")
308
+ case.wait(lambda: case.logs("claude").count("autoresume watch") == 2, "resumed session supervised")
309
+ first, second = case.records("launch")[:2]
310
+ self.assertEqual(second["argv"][:-1], ["--dangerously-skip-permissions", "--effort", "xhigh", "--resume",
311
+ first["sid"]])
312
+ switch = [line for line in case.logs("claude").splitlines() if " autoresume switch " in line]
313
+ self.assertTrue(switch[0].endswith(" probe=acct-02 effort=xhigh"), switch)
314
+
315
+
316
+ if __name__ == "__main__":
317
+ unittest.main()
@@ -0,0 +1,135 @@
1
+ """lib/session_settings.py: the model and effort a session is running with, from its history."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(ROOT / "lib"))
14
+ import session_settings as ss # noqa: E402
15
+
16
+ SID = "01a13956-95c9-7d41-9a8b-b3e5ff79d3b2"
17
+ OTHER = "0aaaaaaa-0000-7000-8000-000000000000"
18
+
19
+
20
+ def codex_line(kind, **payload):
21
+ return json.dumps({"timestamp": "2026-10-14T08:00:00Z", "type": kind, "payload": payload})
22
+
23
+
24
+ def meta(sid=SID):
25
+ return codex_line("session_meta", id=sid, session_id=sid, cwd="/w")
26
+
27
+
28
+ def turn(model, effort=None):
29
+ payload = {"turn_id": "t", "cwd": "/w", "model": model}
30
+ if effort is not None:
31
+ payload["effort"] = effort
32
+ return codex_line("turn_context", **payload)
33
+
34
+
35
+ def applied(model, effort, thread=SID):
36
+ settings = {"model": model, "model_provider_id": "openai"}
37
+ if effort is not None:
38
+ settings["reasoning_effort"] = effort
39
+ return codex_line("event_msg", type="thread_settings_applied", thread_id=thread, thread_settings=settings)
40
+
41
+
42
+ def claude_line(kind, **fields):
43
+ return json.dumps(dict({"type": kind, "sessionId": SID, "isSidechain": False}, **fields))
44
+
45
+
46
+ def reply(model, effort=None, requested=None, **extra):
47
+ fields = dict({"message": {"role": "assistant", "model": model}}, **extra)
48
+ if effort is not None:
49
+ fields["effort"] = effort
50
+ if requested is not None:
51
+ fields["requestedModel"] = requested
52
+ return claude_line("assistant", **fields)
53
+
54
+
55
+ class SessionSettingsTests(unittest.TestCase):
56
+ def setUp(self):
57
+ self.tmp = Path(tempfile.mkdtemp(prefix="ss-"))
58
+ self.addCleanup(shutil.rmtree, str(self.tmp), True)
59
+
60
+ def write(self, name, lines):
61
+ path = self.tmp / name
62
+ path.write_text("\n".join(lines) + ("\n" if lines else ""))
63
+ return str(path)
64
+
65
+ def test_codex_last_turn_context_or_own_settings_snapshot_wins(self):
66
+ path = self.write("r.jsonl", [meta(), turn("gpt-6-astra", "high"), codex_line("event_msg", type="task_started"),
67
+ turn("gpt-6-astra", "ultra")])
68
+ self.assertEqual(ss.session_settings("codex", path), {"model": "gpt-6-astra", "effort": "ultra"})
69
+ # an effort change inside a running turn writes only the settings snapshot: it wins
70
+ path = self.write("r.jsonl", [meta(), turn("gpt-6-astra", "max"), applied("gpt-6-astra", "ultra")])
71
+ self.assertEqual(ss.session_settings("codex", path), {"model": "gpt-6-astra", "effort": "ultra"})
72
+ # a /model switch recorded by a snapshot, then a turn on the new model
73
+ path = self.write("r.jsonl", [meta(), turn("gpt-6-astra", "ultra"), applied("gpt-reserve", "medium"),
74
+ turn("gpt-reserve", "medium")])
75
+ self.assertEqual(ss.session_settings("codex", path), {"model": "gpt-reserve", "effort": "medium"})
76
+ # another thread's snapshot (a forked subagent's parent copy) is not this session's
77
+ path = self.write("r.jsonl", [meta(), turn("gpt-6-astra", "ultra"), applied("gpt-6-luna", "low", thread=OTHER)])
78
+ self.assertEqual(ss.session_settings("codex", path), {"model": "gpt-6-astra", "effort": "ultra"})
79
+ # an absent effort is the model default, reported as None
80
+ path = self.write("r.jsonl", [meta(), turn("gpt-6.1-sol")])
81
+ self.assertEqual(ss.session_settings("codex", path), {"model": "gpt-6.1-sol", "effort": None})
82
+
83
+ def test_codex_bounds_and_junk(self):
84
+ lines = [meta(), turn("gpt-6-astra", "high")]
85
+ path = self.write("r.jsonl", lines)
86
+ boundary = os.path.getsize(path)
87
+ with open(path, "a") as fh:
88
+ fh.write("not json\n" + turn("gpt-6-astra", "ultra") + "\n")
89
+ self.assertEqual(ss.session_settings("codex", path), {"model": "gpt-6-astra", "effort": "ultra"})
90
+ self.assertEqual(ss.session_settings("codex", path, upto=boundary), {"model": "gpt-6-astra", "effort": "high"})
91
+ self.assertEqual(ss.session_settings("codex", self.write("e.jsonl", [meta(), "{", "[1]"])),
92
+ {"model": None, "effort": None})
93
+ self.assertEqual(ss.session_settings("codex", str(self.tmp / "missing")), {"model": None, "effort": None})
94
+ self.assertEqual(ss.session_settings("gemini", path), {"model": None, "effort": None})
95
+ self.assertEqual(ss.session_settings("codex", None), {"model": None, "effort": None})
96
+
97
+ def test_claude_last_main_thread_reply(self):
98
+ path = self.write("c.jsonl", [claude_line("user", message={"role": "user", "content": "hi there"}),
99
+ reply("claude-opus-5-5", "medium"),
100
+ reply("claude-fable-5-1", "max", requested="claude-fable-5-1[1m]"),
101
+ reply("claude-haiku-5-5", "low", isSidechain=True),
102
+ reply("claude-sonnet-5-5", "high", isMeta=True),
103
+ reply("<synthetic>", perTurnEffort="xhigh", isApiErrorMessage=True)])
104
+ self.assertEqual(ss.session_settings("claude", path), {"model": "claude-fable-5-1[1m]", "effort": "max"})
105
+ path = self.write("c.jsonl", [reply("claude-opus-5-5", perTurnEffort="xhigh")])
106
+ self.assertEqual(ss.session_settings("claude", path), {"model": "claude-opus-5-5", "effort": "xhigh"})
107
+ path = self.write("c.jsonl", [reply("claude-opus-5-5")])
108
+ self.assertEqual(ss.session_settings("claude", path), {"model": "claude-opus-5-5", "effort": None})
109
+ self.assertEqual(ss.session_settings("claude", self.write("n.jsonl", [claude_line("progress")])),
110
+ {"model": None, "effort": None})
111
+
112
+ def test_reverse_scan_crosses_chunk_boundaries(self):
113
+ filler = claude_line("progress", data="x" * 5000)
114
+ lines = [reply("claude-opus-5-5", "high")] + [filler] * 600
115
+ path = self.write("big.jsonl", lines)
116
+ self.assertGreater(os.path.getsize(path), 2 * ss.CHUNK)
117
+ self.assertEqual(ss.session_settings("claude", path), {"model": "claude-opus-5-5", "effort": "high"})
118
+ # no trailing newline on the last record
119
+ with open(path, "a") as fh:
120
+ fh.write(reply("claude-sonnet-5-5", "low"))
121
+ self.assertEqual(ss.session_settings("claude", path), {"model": "claude-sonnet-5-5", "effort": "low"})
122
+
123
+ def test_cli(self):
124
+ path = self.write("r.jsonl", [meta(), turn("gpt-6-astra", "ultra")])
125
+ out = subprocess.run([sys.executable, "-I", str(ROOT / "lib/session_settings.py"), "codex", path],
126
+ capture_output=True, text=True)
127
+ self.assertEqual((out.returncode, json.loads(out.stdout)), (0, {"model": "gpt-6-astra", "effort": "ultra"}))
128
+ out = subprocess.run([sys.executable, "-I", str(ROOT / "lib/session_settings.py"), "codex"],
129
+ capture_output=True, text=True)
130
+ self.assertEqual(out.returncode, 2)
131
+ self.assertIn("usage", out.stderr)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()