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.
- package/README.md +7 -2
- package/bin/claude +71 -8
- package/bin/codex +67 -14
- package/docs/AUTORESUME-HANDOFF.md +82 -0
- package/docs/AUTORESUME.md +24 -12
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +60 -16
- package/lib/autoresume_cross.py +143 -0
- package/lib/autoresume_cross_dispatch.py +114 -0
- package/lib/autoresume_cross_receipt.py +138 -0
- package/lib/autoresume_handoff.py +191 -0
- package/lib/autoresume_probe.py +36 -0
- package/package.json +1 -1
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/autoresume_cross_receipt_support.py +58 -0
- package/tests/autoresume_cross_tmux_environment.py +59 -0
- package/tests/autoresume_cross_tmux_support.py +112 -0
- package/tests/autoresume_cross_unit_support.py +92 -0
- package/tests/fixtures/cross_provider_cli.py +121 -0
- package/tests/test_autoresume.py +4 -2
- package/tests/test_autoresume_cross_claude_holders.py +81 -0
- package/tests/test_autoresume_cross_guards.py +133 -0
- package/tests/test_autoresume_cross_holders.py +89 -0
- package/tests/test_autoresume_cross_policy.py +123 -0
- package/tests/test_autoresume_cross_receipt.py +141 -0
- package/tests/test_autoresume_cross_tmux.py +140 -0
- package/tests/test_autoresume_cross_tmux_races.py +45 -0
- package/tests/test_autoresume_cross_tokens.py +102 -0
- package/tests/test_autoresume_handoff_argv.py +127 -0
- 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}
|
package/lib/autoresume_probe.py
CHANGED
|
@@ -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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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()
|