claude-multiacc 2.0.55 → 2.0.57

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 (102) hide show
  1. package/README.md +7 -2
  2. package/bin/claude +71 -8
  3. package/bin/codex +67 -14
  4. package/docs/AUTORESUME-HANDOFF.md +82 -0
  5. package/docs/AUTORESUME.md +24 -12
  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_usage_fetch.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  35. package/lib/autoresume.py +60 -16
  36. package/lib/autoresume_cross.py +143 -0
  37. package/lib/autoresume_cross_dispatch.py +114 -0
  38. package/lib/autoresume_cross_receipt.py +138 -0
  39. package/lib/autoresume_handoff.py +191 -0
  40. package/lib/autoresume_probe.py +36 -0
  41. package/package.json +1 -1
  42. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  87. package/tests/autoresume_cross_receipt_support.py +58 -0
  88. package/tests/autoresume_cross_tmux_environment.py +59 -0
  89. package/tests/autoresume_cross_tmux_support.py +112 -0
  90. package/tests/autoresume_cross_unit_support.py +92 -0
  91. package/tests/fixtures/cross_provider_cli.py +121 -0
  92. package/tests/test_autoresume.py +4 -2
  93. package/tests/test_autoresume_cross_claude_holders.py +81 -0
  94. package/tests/test_autoresume_cross_guards.py +133 -0
  95. package/tests/test_autoresume_cross_holders.py +89 -0
  96. package/tests/test_autoresume_cross_policy.py +123 -0
  97. package/tests/test_autoresume_cross_receipt.py +141 -0
  98. package/tests/test_autoresume_cross_tmux.py +140 -0
  99. package/tests/test_autoresume_cross_tmux_races.py +45 -0
  100. package/tests/test_autoresume_cross_tokens.py +102 -0
  101. package/tests/test_autoresume_handoff_argv.py +127 -0
  102. package/tests/test_autoresume_handoff_history.py +115 -0
@@ -0,0 +1,191 @@
1
+ """Build a fresh cross-provider conversation from one verified local source history.
2
+
3
+ The watcher already knows the source conversation; this module never searches for a
4
+ replacement by directory or recency. It returns launch data without mutating either pool.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+
12
+
13
+ PROVIDERS = ("claude", "codex")
14
+ SID_RE = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
15
+ MAX_SOURCE_BYTES = 4 << 20
16
+ PROMPT_PREFIX = "(claude-multiacc auto-resume)"
17
+
18
+
19
+ def _read_source(path, inode):
20
+ """Read only a regular file and reject replacement while checking its identity."""
21
+ try:
22
+ original = os.fspath(path)
23
+ if not isinstance(original, str) or not os.path.isabs(original) or "\0" in original:
24
+ return None
25
+ resolved = os.path.realpath(original)
26
+ descriptor = os.open(resolved, os.O_RDONLY | os.O_NONBLOCK)
27
+ with os.fdopen(descriptor, "rb") as source:
28
+ before = os.fstat(source.fileno())
29
+ if not stat.S_ISREG(before.st_mode) or before.st_size <= 0:
30
+ return None
31
+ if inode is not None and (type(inode) is not int or inode != before.st_ino):
32
+ return None
33
+ data = source.read(min(before.st_size, MAX_SOURCE_BYTES))
34
+ after = os.stat(resolved)
35
+ if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
36
+ return None
37
+ if after.st_size < before.st_size or os.path.realpath(original) != resolved:
38
+ return None
39
+ return resolved, before, data
40
+ except (OSError, TypeError, ValueError):
41
+ return None
42
+
43
+
44
+ def _codex_identity(data, sid, cwd):
45
+ """A main interactive rollout identifies itself in its first JSONL record."""
46
+ try:
47
+ record = json.loads(data.split(b"\n", 1)[0])
48
+ except (ValueError, UnicodeError):
49
+ return False
50
+ if not isinstance(record, dict) or record.get("type") != "session_meta":
51
+ return False
52
+ meta = record.get("payload")
53
+ if not isinstance(meta, dict) or meta.get("id") != sid or meta.get("session_id") != sid:
54
+ return False
55
+ if meta.get("source") not in (None, "cli") or meta.get("thread_source") not in (None, "user"):
56
+ return False
57
+ if meta.get("originator") not in (None, "codex-tui", "codex_cli_rs", "codex_cli"):
58
+ return False
59
+ directory = meta.get("cwd")
60
+ return isinstance(directory, str) and os.path.isabs(directory) and os.path.realpath(directory) == cwd
61
+
62
+
63
+ def _claude_identity(data, sid, cwd):
64
+ """Skip bookkeeping; at least one main conversational record must bind this cwd."""
65
+ matched = False
66
+ for line in data.splitlines():
67
+ try:
68
+ record = json.loads(line)
69
+ except (ValueError, UnicodeError):
70
+ continue
71
+ if not isinstance(record, dict) or record.get("type") not in ("user", "assistant"):
72
+ continue
73
+ if record.get("isSidechain") is True or record.get("agentId"):
74
+ continue
75
+ if record.get("sessionId") != sid:
76
+ return False
77
+ directory = record.get("cwd")
78
+ if isinstance(directory, str) and os.path.isabs(directory) and os.path.realpath(directory) == cwd:
79
+ matched = True
80
+ return matched
81
+
82
+
83
+ def validate_source(provider, sid, path, cwd, inode=None):
84
+ """Return the exact canonical history identity, or None when it cannot be proven.
85
+
86
+ ``inode`` optionally pins the watcher's already bound file. ``offset`` is the source
87
+ EOF at validation; the prompt references the durable native history, not a copied log.
88
+ """
89
+ if provider not in PROVIDERS or not isinstance(sid, str) or not SID_RE.fullmatch(sid):
90
+ return None
91
+ try:
92
+ directory = os.fspath(cwd)
93
+ if not isinstance(directory, str) or not os.path.isabs(directory) or not os.path.isdir(directory):
94
+ return None
95
+ directory = os.path.realpath(directory)
96
+ except (OSError, TypeError, ValueError):
97
+ return None
98
+ loaded = _read_source(path, inode)
99
+ if loaded is None:
100
+ return None
101
+ resolved, identity, data = loaded
102
+ name = os.path.basename(resolved)
103
+ if provider == "codex":
104
+ if not name.startswith("rollout-") or not name.endswith("-" + sid + ".jsonl"):
105
+ return None
106
+ valid = _codex_identity(data, sid, directory)
107
+ else:
108
+ valid = name == sid + ".jsonl" and _claude_identity(data, sid, directory)
109
+ if not valid:
110
+ return None
111
+ return {"provider": provider, "sid": sid, "path": resolved, "cwd": directory,
112
+ "inode": identity.st_ino, "offset": identity.st_size}
113
+
114
+
115
+ def _permission_argv(source, argv):
116
+ """Map explicit permission intent, never models or provider-specific resume flags."""
117
+ # Import lazily: autoresume imports this helper while initializing its own parsers.
118
+ from autoresume import claude_argv_parse, codex_argv_parse
119
+
120
+ if source == "codex":
121
+ parsed = codex_argv_parse(argv)
122
+ if parsed is None:
123
+ return None
124
+ kept = parsed[0]
125
+ bypass = any(flag in kept for flag in ("--yolo", "--dangerously-bypass-approvals-and-sandbox"))
126
+ if bypass and any(flag in kept for flag in ("--sandbox", "--ask-for-approval")):
127
+ return None
128
+ return ["--dangerously-skip-permissions"] if bypass else ["--permission-mode", "default"]
129
+ kept = claude_argv_parse(argv)
130
+ if kept is None:
131
+ return None
132
+ modes = [kept[index + 1] for index, flag in enumerate(kept) if flag == "--permission-mode"]
133
+ mode = modes[-1] if modes else None
134
+ bypass = "--dangerously-skip-permissions" in kept
135
+ if mode not in (None, "default", "bypassPermissions") or bypass and mode == "default":
136
+ return None
137
+ if bypass or mode == "bypassPermissions":
138
+ return ["--dangerously-bypass-approvals-and-sandbox"]
139
+ return ["--sandbox", "read-only", "--ask-for-approval", "on-request"]
140
+
141
+
142
+ def handoff_prompt(history, target, prompt_override=None):
143
+ """Mandatory identifiers and history instructions remain present with any override."""
144
+ source = history["provider"]
145
+ text = (
146
+ PROMPT_PREFIX + " The " + source + " account hit its usage limit and no other " + source
147
+ + " account has usable quota. This new " + target + " conversation continues the same task. "
148
+ "Source provider: " + source + ". Source conversation ID: " + json.dumps(history["sid"]) + ". "
149
+ "Source history JSONL: " + json.dumps(history["path"]) + ". "
150
+ "Source working directory: " + json.dumps(history["cwd"]) + ". "
151
+ "Source file inode: " + str(history["inode"]) + "; snapshot byte boundary: " + str(history["offset"]) + ". "
152
+ "Verify that file identity and read the complete JSONL records up to that byte boundary before acting; "
153
+ "if the source history is unavailable or replaced, report it rather than choosing another conversation. "
154
+ "Reconstruct the user's objective, "
155
+ "accepted constraints, completed work and remaining work. If it contains earlier cross-provider "
156
+ "handoff references, read those source histories as needed. Continue the unfinished task; the user "
157
+ "has not sent a new message. Background work may have stopped or may still be running. Inspect "
158
+ "its state before relying on it or restarting it, and do not duplicate active or completed work."
159
+ )
160
+ if isinstance(prompt_override, str) and prompt_override.strip() and not any(
161
+ character in prompt_override for character in "\0\n\r"):
162
+ text += " Additional continuation instruction: " + prompt_override.replace(
163
+ "{reason}", "the previous account hit its usage limit")
164
+ return text
165
+
166
+
167
+ def handoff_argv(source_provider, target_provider, original_argv, history, prompt_override=None):
168
+ """Fresh target argv, or None for unsupported source options or permission intent."""
169
+ if source_provider not in PROVIDERS or target_provider not in PROVIDERS or source_provider == target_provider:
170
+ return None
171
+ if not isinstance(history, dict) or history.get("provider") != source_provider:
172
+ return None
173
+ if not all(isinstance(history.get(key), str) for key in ("sid", "path", "cwd")):
174
+ return None
175
+ if not SID_RE.fullmatch(history["sid"]) or not all(os.path.isabs(history[key]) for key in ("path", "cwd")):
176
+ return None
177
+ if not all(type(history.get(key)) is int and history[key] > 0 for key in ("inode", "offset")):
178
+ return None
179
+ if not isinstance(original_argv, (list, tuple)) or not all(isinstance(arg, str) for arg in original_argv):
180
+ return None
181
+ options = _permission_argv(source_provider, original_argv)
182
+ return None if options is None else options + [handoff_prompt(history, target_provider, prompt_override)]
183
+
184
+
185
+ def build_handoff(source_provider, target_provider, argv, sid, transcript, cwd, inode=None, prompt_override=None):
186
+ """Validate source history and return ``{argv, history}`` without changing any state."""
187
+ history = validate_source(source_provider, sid, transcript, cwd, inode)
188
+ if history is None:
189
+ return None
190
+ target_argv = handoff_argv(source_provider, target_provider, argv, history, prompt_override)
191
+ return None if target_argv is None else {"argv": target_argv, "history": history}
@@ -48,3 +48,39 @@ def probe_allows(cls, pick, tier, current):
48
48
  if cls in ROTATE:
49
49
  return tier == "eligible" and bool(pick) and pick != current
50
50
  return tier in ("eligible", "soft", "credits") and bool(pick)
51
+
52
+
53
+ def probe_exhausted(rc, pick, tier):
54
+ """Only the shim's complete no-capacity responses justify changing providers."""
55
+ return (rc == 0 and tier == "hard" and bool(pick)) or (rc == 3 and tier == "none" and not pick)
56
+
57
+
58
+ CROSS_DROP = set(sum(PROBE_DROP.values(), ())) | {'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'}
59
+ CONFIG_SUFFIXES = ('ACCOUNTS_ROOT', 'ACCOUNTS_DIR', 'MULTIACC_AUTORESUME', 'MULTIACC_CROSS_PROVIDER',
60
+ 'MULTIACC_DISABLE', 'MULTIACC_HEADROOM_BAND', 'MULTIACC_SESSION_GATE',
61
+ 'MULTIACC_THRESHOLD', 'MULTIACC_CLIENT_LIMITS', 'MULTIACC_CLIENT_SCAN_TTL',
62
+ 'MULTIACC_CREDITS', 'MULTIACC_KEYCHAIN', 'MULTIACC_MCP', 'MULTIACC_PYTHON',
63
+ 'MULTIACC_STALE_AFTER', 'SHIM_SELECT', 'SHIM_RETRY',
64
+ 'MULTIACC_AUTORESUME_PROMPT', 'MULTIACC_AUTORESUME_DEBUG')
65
+
66
+
67
+ def cross_env(environ, roots):
68
+ """The same subscription-pool environment for destination probing and execution."""
69
+ from autoresume import KNOBS
70
+ knobs = set(KNOBS) | {'TRANSIENT_LADDER', 'TEST_TMUX_ANYPANE', 'TEST_TTY'}
71
+ keep = {up + '_MULTIACC_AR_' + name for up in ('CLAUDE', 'CODEX') for name in knobs}
72
+ env = {key: value for key, value in environ.items() if key not in CROSS_DROP
73
+ and (key in keep or not any(key.startswith(up + '_MULTIACC_AR_') for up in ('CLAUDE', 'CODEX')))
74
+ and key not in ('CLAUDE_MULTIACC_PREFLIGHT_DEPTH', 'CLAUDE_MULTIACC_PREFLIGHT_SKIP')}
75
+ for provider, root in roots.items():
76
+ env[provider.upper() + '_ACCOUNTS_ROOT'] = root
77
+ return env
78
+
79
+
80
+ def handoff_settings(environ, roots):
81
+ """Persist only noncredential operator settings; API keys/tokens never enter relaunch artifacts."""
82
+ from autoresume import KNOBS
83
+ knobs = set(KNOBS) | {'TRANSIENT_LADDER', 'TEST_TMUX_ANYPANE', 'TEST_TTY'}
84
+ allowed = {up + '_MULTIACC_AR_' + name for up in ('CLAUDE', 'CODEX') for name in knobs}
85
+ allowed |= {up + '_' + suffix for up in ('CLAUDE', 'CODEX') for suffix in CONFIG_SUFFIXES}
86
+ return {key: value for key, value in cross_env(environ, roots).items() if key in allowed}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.55",
3
+ "version": "2.0.57",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,58 @@
1
+ """Synthetic destination startup receipts tied to exact simulated client identities."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import time
6
+
7
+ import test_autoresume as fixtures
8
+ from autoresume_cross_unit_support import scenario
9
+ import autoresume_cross as cross
10
+
11
+ TOKEN = "CrossReceipt0123456789abcdef"
12
+ TARGET_PID = 6565
13
+
14
+
15
+ def target_history(watcher, plan, state, timestamp, prompt):
16
+ account = Path(state["acct_dir"])
17
+ sid = fixtures.SID2 if plan["provider"] == "claude" else fixtures.CX_SID2
18
+ if plan["provider"] == "claude":
19
+ registry = account / "sessions" / (str(TARGET_PID) + ".json")
20
+ registry.parent.mkdir(parents=True, exist_ok=True)
21
+ registry.write_text(json.dumps({"pid": TARGET_PID, "sessionId": sid, "cwd": state["cwd"],
22
+ "startedAt": timestamp * 1000}))
23
+ path = account / "projects/project" / (sid + ".jsonl")
24
+ records = [fixtures.cc_user(timestamp, prompt, sessionId=sid, cwd=state["cwd"])]
25
+ else:
26
+ path = account / "sessions/2026/10/11" / ("rollout-fixture-" + sid + ".jsonl")
27
+ records = [fixtures.cx_meta(timestamp, sid=sid, cwd=state["cwd"]),
28
+ fixtures.cx_event(timestamp, {"type": "user_message", "message": prompt})]
29
+ directory = Path(plan["root"]) / "tmp/autoresume"
30
+ (directory / (str(TARGET_PID) + ".rollout")).write_text(str(path))
31
+ path.parent.mkdir(parents=True, exist_ok=True)
32
+ path.write_text(fixtures.jl(records))
33
+ return sid, path
34
+
35
+
36
+ def receipt_scenario(test, provider):
37
+ case, watcher, runner, _, root = scenario(test, provider)
38
+ plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
39
+ assert plan is not None
40
+ target_dir = root / "tmp/autoresume"
41
+ target_dir.mkdir(parents=True)
42
+ started = time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(runner.now()))
43
+ state = dict(watcher.st, v="1", provider=plan["provider"], pid=TARGET_PID, acct="acct-01",
44
+ acct_dir=str(root / "acct-01"), acc_root=str(root), self=plan["shim"],
45
+ launched=int(runner.now()), chain=watcher.chain, depth=int(watcher.st["depth"]) + 1,
46
+ handoff_token=TOKEN)
47
+ path = target_dir / (str(TARGET_PID) + ".state")
48
+ path.write_text("".join(str(key) + "=" + str(value) + "\n" for key, value in state.items()))
49
+ path.with_suffix(".argv").write_bytes(fixtures.ar.encode_argv(plan["argv"]))
50
+ receipt = {key: state[key] for key in ("provider", "pid", "acct", "pane", "ppid", "chain", "depth", "launched")}
51
+ receipt.update(token=TOKEN, state=str(path), started=started)
52
+ receipt_path = target_dir / ("h-" + TOKEN + ".start.json")
53
+ receipt_path.write_text(json.dumps(receipt))
54
+ receipt_path.chmod(0o600)
55
+ runner.live.add(TARGET_PID)
56
+ runner.starts[TARGET_PID] = started
57
+ sid, history = target_history(watcher, plan, state, runner.now(), plan["argv"][-1])
58
+ return case, watcher, runner, plan, receipt_path, path, history, sid
@@ -0,0 +1,59 @@
1
+ """Credential-free disposable pools and process environment for cross-provider tests."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+
8
+ from autoresume_tmux_support import ORIGINAL_PATH, ROOT
9
+
10
+
11
+ def seed_pool(home, provider):
12
+ pool = home / (provider + "-pool")
13
+ pool.mkdir()
14
+ shared = home / ("." + provider) / ("sessions" if provider == "codex" else "projects")
15
+ shared.mkdir(parents=True)
16
+ (pool / "accounts.json").write_text(json.dumps({"threshold": 90, "accounts": [
17
+ {"id": name, "email": name + "@synthetic.invalid", "home": "mac"}
18
+ for name in ("acct-01", "acct-02")]}))
19
+ (pool / ".limits-kick").touch()
20
+ for name, percent in (("acct-01", 10), ("acct-02", 30)):
21
+ account = pool / name
22
+ account.mkdir()
23
+ if provider == "codex":
24
+ (account / "auth.json").write_text('{"tokens":{"access_token":"test-only"}}')
25
+ (account / "sessions").symlink_to(shared, target_is_directory=True)
26
+ else:
27
+ (account / ".credentials.json").write_text(json.dumps({"claudeAiOauth": {
28
+ "accessToken": "test-only", "refreshToken": "test-only", "expiresAt": 9999999999999,
29
+ "refreshTokenExpiresAt": 9999999999999, "scopes": ["user:inference"], "subscriptionType": "max"}}))
30
+ (account / "projects").symlink_to(shared, target_is_directory=True)
31
+ (account / ".claude.json").write_text('{"hasCompletedOnboarding":true}')
32
+ (account / "limits.json").write_text(json.dumps({"fetched_at": int(time.time()), "buckets": [],
33
+ "max_percent": percent, "weekly_percent": percent, "session_percent": 10}, indent=1))
34
+ return pool
35
+
36
+
37
+ def test_environment(home, pools, overrides):
38
+ fake = home / "fake"
39
+ fake.mkdir()
40
+ for provider in pools:
41
+ binary = fake / provider
42
+ binary.write_text("#!" + sys.executable + "\n" +
43
+ (ROOT / "tests/fixtures/cross_provider_cli.py").read_text())
44
+ binary.chmod(0o755)
45
+ env = {"HOME": str(home), "PATH": str(fake) + os.pathsep + ORIGINAL_PATH,
46
+ "TERM": "xterm-256color", "SHELL": "/bin/bash", "LC_ALL": "C", "FAKE_HOME": str(home)}
47
+ for provider, pool in pools.items():
48
+ prefix = provider.upper() + "_"
49
+ env[prefix + "ACCOUNTS_ROOT"] = str(pool)
50
+ for name, value in {"PYTHON": sys.executable, "AUTORESUME": "1", "AUTORESUME_DEBUG": "1",
51
+ "MCP": "0", "AUTO_RESET": "0", "NO_SYNC": "1", "CLIENT_LIMITS": "0",
52
+ "HEADROOM_BAND": "0", "KEYCHAIN": "0", "PATH_PROBE": "0",
53
+ "USAGE_URL": "file:///nonexistent", "TOKEN_URL": "file:///nonexistent",
54
+ "AR_POLL": "0.05", "AR_GRACE": "0.4", "AR_HOLD_REPROBE": "0.3",
55
+ "AR_TERM_GRACE": "0.3", "AR_PANE_WAIT": "4", "AR_RELAUNCH_WAIT": "5",
56
+ "AR_PROMPT_WAIT": "2", "AR_TRANSIENT_LADDER": "0.4"}.items():
57
+ env[prefix + "MULTIACC_" + name] = value
58
+ env.update(overrides)
59
+ return env
@@ -0,0 +1,112 @@
1
+ """Real isolated tmux, launch shell, production shims and watchers for both providers."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shlex
7
+ import signal
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+
12
+ from autoresume_cross_tmux_environment import seed_pool, test_environment
13
+ from autoresume_tmux_support import ROOT, TMUX
14
+
15
+
16
+ class CrossScenario:
17
+ def __init__(self, **overrides):
18
+ self.temp = tempfile.TemporaryDirectory(prefix="ar-cross-tmux-", dir="/tmp")
19
+ self.home = Path(self.temp.name).resolve()
20
+ self.pools = {provider: seed_pool(self.home, provider) for provider in ("codex", "claude")}
21
+ self.work = self.home / "work"
22
+ self.work.mkdir()
23
+ self.sock = self.home / "tmux.sock"
24
+ self.env = test_environment(self.home, self.pools, overrides)
25
+ self.pane = "%0"
26
+
27
+ def tmux(self, *args, check=True):
28
+ result = subprocess.run([TMUX, "-S", str(self.sock), "-f", "/dev/null", *args],
29
+ env=self.env, capture_output=True, text=True, timeout=10)
30
+ if check and result.returncode:
31
+ raise AssertionError(result.stderr)
32
+ return result.stdout.strip()
33
+
34
+ def start(self, provider, args=None):
35
+ self.pane = self.tmux("new-session", "-d", "-P", "-F", "#{pane_id}", "-s", "case",
36
+ "-c", str(self.work), "/bin/bash", "--noprofile", "--norc", "-i")
37
+ flag = "--yolo" if provider == "codex" else "--dangerously-skip-permissions"
38
+ arguments = [flag] if args is None else args
39
+ self.tmux("send-keys", "-t", self.pane, "-l", shlex.join([str(ROOT / "bin" / provider), *arguments]))
40
+ self.tmux("send-keys", "-t", self.pane, "Enter")
41
+ self.wait(lambda: len(self.records("ready")) == 1, "first fake TUI ready")
42
+ self.wait(lambda: "autoresume watch" in self.logs(provider), "source watcher started")
43
+
44
+ def records(self, kind=None):
45
+ try:
46
+ rows = [json.loads(line) for line in (self.home / "provider.jsonl").read_text().splitlines()]
47
+ except (OSError, ValueError):
48
+ return []
49
+ return [row for row in rows if kind is None or row["kind"] == kind]
50
+
51
+ def logs(self, provider=None):
52
+ result = ""
53
+ for name, pool in self.pools.items():
54
+ if provider is None or name == provider:
55
+ path = pool / "selection.log"
56
+ result += path.read_text() if path.exists() else ""
57
+ return result
58
+
59
+ def quota(self, ready=None):
60
+ ready = ready or self.records("ready")[-1]
61
+ stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
62
+ if ready["provider"] == "codex":
63
+ record = {"timestamp": stamp, "type": "event_msg", "payload": {"type": "task_complete", "error": {
64
+ "message": "You've hit your usage limit. Try again later.",
65
+ "codex_error_info": "usage_limit_exceeded"}}}
66
+ else:
67
+ record = {"timestamp": stamp, "type": "assistant", "sessionId": ready["sid"],
68
+ "cwd": str(self.work), "isSidechain": False, "isApiErrorMessage": True,
69
+ "apiErrorStatus": 429, "error": "rate_limit", "message": {"role": "assistant",
70
+ "content": [{"type": "text", "text": "You've hit your limit · resets 3:36am"}]},
71
+ "quotaLimits": {"status": "rejected", "rateLimitType": "five_hour"}}
72
+ with Path(ready["path"]).open("a") as target:
73
+ target.write(json.dumps(record) + "\n")
74
+
75
+ def park(self, provider, names=("acct-02",)):
76
+ for name in names:
77
+ (self.pools[provider] / name / ".limited").write_text(
78
+ f"{int(time.time()) + 3600}\nbucket=error-cooldown reason=error-cooldown\n")
79
+
80
+ def wait(self, predicate, label, timeout=15):
81
+ deadline = time.monotonic() + timeout
82
+ while time.monotonic() < deadline:
83
+ if predicate():
84
+ return
85
+ time.sleep(0.05)
86
+ logs = self.logs() + "\n" + self.tmux("capture-pane", "-p", "-t", self.pane, check=False)
87
+ for pool in self.pools.values():
88
+ for path in (pool / "tmp/autoresume").glob("*.log"):
89
+ logs += "\n" + path.read_text()
90
+ raise AssertionError(label + " timed out:\n" + logs)
91
+
92
+ def close(self):
93
+ if not self.home.exists():
94
+ return
95
+ for pool in self.pools.values():
96
+ (pool / "autoresume.off").touch()
97
+ deadline = time.monotonic() + 3
98
+ while any(self.home.glob("*-pool/tmp/autoresume/*.state")) and time.monotonic() < deadline:
99
+ time.sleep(0.05)
100
+ if self.sock.exists():
101
+ self.tmux("kill-server", check=False)
102
+ processes = subprocess.run(["ps", "-A", "-o", "pid=,args="], capture_output=True,
103
+ text=True, env=self.env, timeout=5).stdout
104
+ owned = ["watch --state " + str(pool / "tmp/autoresume") + "/" for pool in self.pools.values()]
105
+ for row in processes.splitlines():
106
+ parts = row.strip().split(None, 1)
107
+ if len(parts) == 2 and any(marker in parts[1] for marker in owned):
108
+ try:
109
+ os.kill(int(parts[0]), signal.SIGTERM)
110
+ except ProcessLookupError:
111
+ pass
112
+ self.temp.cleanup()