claude-multiacc 2.0.56 → 2.0.58
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 +84 -0
- package/docs/AUTORESUME.md +16 -5
- 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 +58 -14
- 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_origin.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_handoff_origins.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 +122 -0
- package/tests/test_autoresume.py +3 -1
- 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_origin.py +70 -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
- package/tests/test_autoresume_handoff_origins.py +53 -0
package/lib/autoresume.py
CHANGED
|
@@ -57,8 +57,10 @@ import traceback
|
|
|
57
57
|
|
|
58
58
|
# -I omits the script directory; load only helpers from this installation.
|
|
59
59
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
60
|
-
from autoresume_probe import ACCT_RE, ROTATE, parse_probe, probe_allows, probe_env
|
|
60
|
+
from autoresume_probe import ACCT_RE, ROTATE, parse_probe, probe_allows, probe_env, probe_exhausted
|
|
61
61
|
from autoresume_receipt import apply_cursor, confirm_prompt, receipt_tail
|
|
62
|
+
from autoresume_cross import chain_fields, plan_fallback, prepare as prepare_cross
|
|
63
|
+
from autoresume_cross_receipt import confirm_cross
|
|
62
64
|
|
|
63
65
|
# ---- vocabulary ---------------------------------------------------------------------
|
|
64
66
|
|
|
@@ -127,9 +129,10 @@ MONTHS = {m: i for i, m in enumerate(
|
|
|
127
129
|
|
|
128
130
|
STATE_KEYS = ("v", "provider", "pid", "ppid", "acct", "acct_dir", "cwd", "launched", "tmux",
|
|
129
131
|
"pane", "self", "acc_root", "depth", "avoid", "hist", "chain", "funding_tier",
|
|
130
|
-
"rollout_cursor")
|
|
132
|
+
"rollout_cursor", "avoid_claude", "avoid_codex", "pool_claude", "pool_codex", "handoff_token")
|
|
131
133
|
RELAUNCH_KEYS = ("v", "provider", "class", "acct", "reset", "rtype", "marked", "sid", "cwd",
|
|
132
|
-
"depth", "avoid", "hist", "chain", "funding_tier", "funding_pid", "rollout_cursor"
|
|
134
|
+
"depth", "avoid", "hist", "chain", "funding_tier", "funding_pid", "rollout_cursor",
|
|
135
|
+
"avoid_claude", "avoid_codex", "pool_claude", "pool_codex", "handoff_token", "cross")
|
|
133
136
|
HIST_KEEP = 32
|
|
134
137
|
|
|
135
138
|
# ---- knobs --------------------------------------------------------------------------
|
|
@@ -710,10 +713,18 @@ def codex_argv_parse(argv):
|
|
|
710
713
|
"""The argv shapes a codex relaunch can rebuild (mirrors bin/codex's ar_argv_ok) ->
|
|
711
714
|
(kept options, resume target or None), or None for anything else."""
|
|
712
715
|
kept, resume, target, prompt, i = [], False, None, False, 0
|
|
716
|
+
conservative = set()
|
|
713
717
|
while i < len(argv):
|
|
714
718
|
arg = argv[i]
|
|
715
719
|
if arg in ("--dangerously-bypass-approvals-and-sandbox", "--yolo", "--no-daemon"):
|
|
716
720
|
kept.append(arg)
|
|
721
|
+
elif arg in ("--sandbox", "--ask-for-approval"):
|
|
722
|
+
expected = "read-only" if arg == "--sandbox" else "on-request"
|
|
723
|
+
if arg in conservative or i + 1 >= len(argv) or argv[i + 1] != expected:
|
|
724
|
+
return None
|
|
725
|
+
conservative.add(arg)
|
|
726
|
+
kept += [arg, expected]
|
|
727
|
+
i += 1
|
|
717
728
|
elif arg in ("-m", "--model"):
|
|
718
729
|
if i + 1 >= len(argv) or not _value_ok(argv[i + 1]):
|
|
719
730
|
return None
|
|
@@ -740,6 +751,9 @@ def codex_argv_parse(argv):
|
|
|
740
751
|
i += 1
|
|
741
752
|
if resume and target is None:
|
|
742
753
|
return None # a bare `resume` is the picker: no session to follow
|
|
754
|
+
if conservative and (len(conservative) != 2 or any(
|
|
755
|
+
arg in kept for arg in ("--yolo", "--dangerously-bypass-approvals-and-sandbox"))):
|
|
756
|
+
return None
|
|
743
757
|
return kept, target
|
|
744
758
|
|
|
745
759
|
|
|
@@ -809,7 +823,7 @@ def format_relaunch(fields):
|
|
|
809
823
|
value carrying a line break (or NUL) cannot be written faithfully: ValueError."""
|
|
810
824
|
lines = []
|
|
811
825
|
for key in RELAUNCH_KEYS:
|
|
812
|
-
if key in
|
|
826
|
+
if key in RELAUNCH_KEYS[13:] and key not in fields:
|
|
813
827
|
continue
|
|
814
828
|
val = "" if fields.get(key) is None else str(fields[key])
|
|
815
829
|
if any(c in val for c in "\0\n\r"):
|
|
@@ -946,6 +960,8 @@ def load_state(path):
|
|
|
946
960
|
"avoid": kv.get("avoid", ""), "hist": kv.get("hist", ""), "chain": chain,
|
|
947
961
|
"funding_tier": "credits" if kv.get("funding_tier") == "credits" else "included",
|
|
948
962
|
"rollout_cursor": kv.get("rollout_cursor", ""),
|
|
963
|
+
**{key: kv.get(key, "") for key in ("avoid_claude", "avoid_codex", "pool_claude",
|
|
964
|
+
"pool_codex", "handoff_token")},
|
|
949
965
|
}
|
|
950
966
|
|
|
951
967
|
|
|
@@ -1409,6 +1425,7 @@ class Watcher:
|
|
|
1409
1425
|
self.never_seen = set()
|
|
1410
1426
|
self.hold_logged = False
|
|
1411
1427
|
self.last_probe = None
|
|
1428
|
+
self.cross_plan = None
|
|
1412
1429
|
self.claim = os.path.join(self.stdir, "%d.rollout" % self.pid)
|
|
1413
1430
|
self.root = None # the pool, when the relaunch has to name it
|
|
1414
1431
|
self.stopped = False # True once this watcher has signalled the client
|
|
@@ -1775,10 +1792,14 @@ class Watcher:
|
|
|
1775
1792
|
env=probe_env(self.env, self.provider, avoid), cwd=cwd)
|
|
1776
1793
|
pick, tier = parse_probe(out)
|
|
1777
1794
|
self.dbg("probe rc=%s pick=%s tier=%s" % (rc, pick, tier))
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1795
|
+
self.cross_plan = None
|
|
1796
|
+
if rc != 0 or not probe_allows(cls, pick, tier, self.acct):
|
|
1797
|
+
if probe_exhausted(rc, pick, tier):
|
|
1798
|
+
self.cross_plan = plan_fallback(self, verdict, avoid)
|
|
1799
|
+
if self.cross_plan is None:
|
|
1800
|
+
self.hold(verdict, reason="no-room", pick=pick or "-", tier=tier or "-")
|
|
1801
|
+
return None
|
|
1802
|
+
pick = self.cross_plan["pick"]
|
|
1782
1803
|
# The probe can take seconds: whatever the session wrote meanwhile (the user
|
|
1783
1804
|
# typing, auto-continue getting through) is read before anything is stopped.
|
|
1784
1805
|
self.read()
|
|
@@ -1831,8 +1852,20 @@ class Watcher:
|
|
|
1831
1852
|
doc = json.loads(fh.read(1 << 20).decode("utf-8", "replace"))
|
|
1832
1853
|
except (OSError, ValueError):
|
|
1833
1854
|
continue
|
|
1834
|
-
if isinstance(doc, dict)
|
|
1835
|
-
|
|
1855
|
+
if not isinstance(doc, dict) or doc.get("sessionId") != self.sid \
|
|
1856
|
+
or type(doc.get("pid")) is not int or doc["pid"] != int(name):
|
|
1857
|
+
continue
|
|
1858
|
+
registered = doc.get("startedAt")
|
|
1859
|
+
if not isinstance(registered, (int, float)) or isinstance(registered, bool):
|
|
1860
|
+
continue
|
|
1861
|
+
started = self.r.lstart(int(name))
|
|
1862
|
+
try:
|
|
1863
|
+
epoch = time.mktime(time.strptime(started, "%a %b %d %H:%M:%S %Y"))
|
|
1864
|
+
if not epoch <= registered / 1000.0 <= self.r.now() + 1:
|
|
1865
|
+
continue
|
|
1866
|
+
except (TypeError, ValueError, OverflowError):
|
|
1867
|
+
continue
|
|
1868
|
+
if self.ours_alive(int(name), started):
|
|
1836
1869
|
return int(name)
|
|
1837
1870
|
return None
|
|
1838
1871
|
|
|
@@ -1964,13 +1997,14 @@ class Watcher:
|
|
|
1964
1997
|
now = self.r.now()
|
|
1965
1998
|
token = make_token()
|
|
1966
1999
|
receipt = None
|
|
1967
|
-
if self.provider == "codex":
|
|
2000
|
+
if self.provider == "codex" and self.cross_plan is None:
|
|
1968
2001
|
receipt = receipt_tail(self.transcript, JsonlTail)
|
|
1969
2002
|
if receipt is None:
|
|
1970
2003
|
self.giveup("receipt", sid=self.sid, **{"class": cls})
|
|
1971
2004
|
fields = relaunch_fields(self.provider, verdict, self.acct, self.sid, cwd,
|
|
1972
2005
|
self.st["depth"], avoid,
|
|
1973
2006
|
hist_append(self.st["hist"], cls, now), self.chain)
|
|
2007
|
+
fields.update(chain_fields(self, avoid))
|
|
1974
2008
|
if receipt is not None:
|
|
1975
2009
|
fields["rollout_cursor"] = "%d:%d" % (receipt.ino, receipt.off)
|
|
1976
2010
|
if self.provider == "codex" and self.st.get("funding_tier") == "credits":
|
|
@@ -1978,7 +2012,10 @@ class Watcher:
|
|
|
1978
2012
|
try:
|
|
1979
2013
|
keys = send_keys_argvs(sock, self.st["pane"], self.provider, token, self.sid,
|
|
1980
2014
|
self.st["self"], self.root)
|
|
1981
|
-
|
|
2015
|
+
if self.cross_plan is not None:
|
|
2016
|
+
keys, paths = prepare_cross(self, verdict, avoid, token, argv)
|
|
2017
|
+
else:
|
|
2018
|
+
paths = write_relaunch(self.ar_dir, token, fields, argv)
|
|
1982
2019
|
except (OSError, ValueError):
|
|
1983
2020
|
self.giveup("write", sid=self.sid, **{"class": cls})
|
|
1984
2021
|
# 3. Stop the client (a crash needs no stopping).
|
|
@@ -2019,9 +2056,16 @@ class Watcher:
|
|
|
2019
2056
|
# Token consumption alone does not prove Codex submitted the continuation.
|
|
2020
2057
|
if receipt is not None and not confirm_prompt(self, receipt, argv[-1]):
|
|
2021
2058
|
self.giveup("prompt", sid=self.sid, **{"class": cls})
|
|
2059
|
+
target = {}
|
|
2060
|
+
if self.cross_plan is not None:
|
|
2061
|
+
target_sid = confirm_cross(self, token, self.cross_plan)
|
|
2062
|
+
if target_sid is None:
|
|
2063
|
+
remove_quietly(*paths)
|
|
2064
|
+
self.giveup("prompt", sid=self.sid, **{"class": cls})
|
|
2065
|
+
target = {"to_provider": self.cross_plan["provider"], "to_sid": target_sid}
|
|
2022
2066
|
# 7.
|
|
2023
|
-
self.log("switch", **{"from": self.acct, "class": cls, "sid": self.sid,
|
|
2024
|
-
|
|
2067
|
+
self.log("switch", **dict({"from": self.acct, "class": cls, "sid": self.sid,
|
|
2068
|
+
"depth": int(self.st["depth"]) + 1, "probe": probe_pick or "-"}, **target))
|
|
2025
2069
|
return 0
|
|
2026
2070
|
|
|
2027
2071
|
def on_exit(self):
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Quota-only planning and token preparation for an exact cross-provider handoff."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from autoresume_handoff import build_handoff, validate_source
|
|
8
|
+
from autoresume_probe import cross_env, parse_probe, probe_allows
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pool_roots(watcher):
|
|
12
|
+
roots = {}
|
|
13
|
+
for provider in ('claude', 'codex'):
|
|
14
|
+
up = provider.upper()
|
|
15
|
+
value = (watcher.st.get('pool_' + provider) or watcher.env.get(up + '_ACCOUNTS_ROOT')
|
|
16
|
+
or watcher.env.get(up + '_ACCOUNTS_DIR')
|
|
17
|
+
or os.path.join((watcher.env.get('HOME') or '/nonexistent'), '.' + provider + '-accounts'))
|
|
18
|
+
roots[provider] = os.path.abspath(value)
|
|
19
|
+
roots[watcher.provider] = os.path.abspath(watcher.st['acc_root'])
|
|
20
|
+
return roots
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def chain_fields(watcher, avoid):
|
|
24
|
+
from autoresume import typeable
|
|
25
|
+
roots = pool_roots(watcher)
|
|
26
|
+
fields = {'pool_' + provider: root if typeable(root) else '' for provider, root in roots.items()}
|
|
27
|
+
for provider in ('claude', 'codex'):
|
|
28
|
+
fields['avoid_' + provider] = (avoid if provider == watcher.provider
|
|
29
|
+
else watcher.st.get('avoid_' + provider, ''))
|
|
30
|
+
return fields
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def enabled(watcher, provider, root):
|
|
34
|
+
up = provider.upper()
|
|
35
|
+
off = ('0', 'false', 'no', 'off')
|
|
36
|
+
return (watcher.env.get(up + '_MULTIACC_AUTORESUME', '1').lower() not in off
|
|
37
|
+
and watcher.env.get(up + '_MULTIACC_DISABLE', '0') != '1'
|
|
38
|
+
and not os.path.exists(os.path.join(root, 'autoresume.off'))
|
|
39
|
+
and os.path.isfile(os.path.join(root, 'accounts.json')))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def shared_source(watcher):
|
|
43
|
+
"""A cross-provider new chat must not fork a source conversation held by another TUI."""
|
|
44
|
+
if watcher.provider == 'claude':
|
|
45
|
+
return watcher.other_holder()
|
|
46
|
+
from autoresume import load_state, rollout_meta
|
|
47
|
+
rows = {pid: (parent, started) for pid, parent, started in watcher.r.ps_table()}
|
|
48
|
+
for claim in glob.glob(os.path.join(watcher.ar_dir, '*.rollout')):
|
|
49
|
+
if claim == watcher.claim:
|
|
50
|
+
continue
|
|
51
|
+
state = load_state(claim[:-8] + '.state')
|
|
52
|
+
if not state or state['provider'] != watcher.provider or state['acc_root'] != watcher.st['acc_root'] \
|
|
53
|
+
or os.path.basename(claim) != str(state['pid']) + '.rollout':
|
|
54
|
+
continue
|
|
55
|
+
identity = rows.get(state['pid'])
|
|
56
|
+
if not identity or identity[0] != state['ppid'] or not identity[1] or watcher.r.gone(state['pid']):
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
epoch = time.mktime(time.strptime(identity[1], '%a %b %d %H:%M:%S %Y'))
|
|
60
|
+
if epoch > state['launched'] + 1:
|
|
61
|
+
continue
|
|
62
|
+
with open(claim, encoding='utf-8') as stream:
|
|
63
|
+
meta = rollout_meta(stream.read(16384).strip())
|
|
64
|
+
except (OSError, ValueError, OverflowError):
|
|
65
|
+
continue
|
|
66
|
+
if meta and meta['id'] == watcher.sid and watcher.r.lstart(state['pid']) == identity[1]:
|
|
67
|
+
return state['pid']
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plan_fallback(watcher, verdict, source_avoid):
|
|
72
|
+
from autoresume import avoid_merge, typeable
|
|
73
|
+
if verdict['class'] != 'quota' or watcher.env.get(
|
|
74
|
+
watcher.provider.upper() + '_MULTIACC_CROSS_PROVIDER', '1').lower() in ('0', 'false', 'no', 'off'):
|
|
75
|
+
return None
|
|
76
|
+
target = 'claude' if watcher.provider == 'codex' else 'codex'
|
|
77
|
+
roots = pool_roots(watcher)
|
|
78
|
+
if os.path.realpath(roots['claude']) == os.path.realpath(roots['codex']):
|
|
79
|
+
return None
|
|
80
|
+
if not all(typeable(root) and enabled(watcher, provider, root) for provider, root in roots.items()):
|
|
81
|
+
return None
|
|
82
|
+
shim = os.path.join(os.path.dirname(watcher.st['self']), target)
|
|
83
|
+
if not typeable(shim) or not os.path.isfile(shim) or not os.access(shim, os.X_OK):
|
|
84
|
+
return None
|
|
85
|
+
if shared_source(watcher):
|
|
86
|
+
watcher.hold(verdict, reason='shared-source')
|
|
87
|
+
return None
|
|
88
|
+
handoff = build_handoff(watcher.provider, target, watcher.argv, watcher.sid, watcher.transcript,
|
|
89
|
+
watcher.session_cwd(), inode=watcher.tail.ino if watcher.tail else None,
|
|
90
|
+
prompt_override=watcher.cfg.prompt)
|
|
91
|
+
if not handoff:
|
|
92
|
+
watcher.hold(verdict, reason='handoff-history-or-permissions')
|
|
93
|
+
return None
|
|
94
|
+
avoid = avoid_merge(watcher.st.get('avoid_' + target, ''), watcher.r.now())
|
|
95
|
+
env = cross_env(watcher.env, roots)
|
|
96
|
+
env[target.upper() + '_MULTIACC_AR_PROBE'] = '1'
|
|
97
|
+
env[target.upper() + '_MULTIACC_AR_AVOID'] = avoid
|
|
98
|
+
rc, out = watcher.r.run([shim] + handoff['argv'], watcher.cfg.probe_timeout,
|
|
99
|
+
env=env, cwd=handoff['history']['cwd'])
|
|
100
|
+
pick, tier = parse_probe(out)
|
|
101
|
+
if rc != 0 or not probe_allows('quota', pick, tier, ''):
|
|
102
|
+
return None
|
|
103
|
+
return dict(handoff, provider=target, root=roots[target], roots=roots, shim=shim,
|
|
104
|
+
avoid=avoid, source_avoid=source_avoid, pick=pick)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def revalidate(watcher, plan):
|
|
108
|
+
history = plan['history']
|
|
109
|
+
current = validate_source(watcher.provider, watcher.sid, watcher.transcript,
|
|
110
|
+
watcher.session_cwd(), inode=history['inode'])
|
|
111
|
+
return (current is not None and current['offset'] >= history['offset'] and not shared_source(watcher)
|
|
112
|
+
and all(enabled(watcher, provider, root) for provider, root in plan['roots'].items()))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prepare(watcher, verdict, avoid, token, source_argv):
|
|
116
|
+
from autoresume import hist_append, relaunch_fields, remove_quietly, send_keys_argvs
|
|
117
|
+
from autoresume import tmux_socket, write_relaunch
|
|
118
|
+
from autoresume_cross_dispatch import save_dispatch
|
|
119
|
+
plan = watcher.cross_plan
|
|
120
|
+
if not revalidate(watcher, plan):
|
|
121
|
+
raise ValueError('handoff source or target changed')
|
|
122
|
+
fields = relaunch_fields(watcher.provider, verdict, watcher.acct, watcher.sid,
|
|
123
|
+
plan['history']['cwd'], watcher.st['depth'], avoid,
|
|
124
|
+
hist_append(watcher.st['hist'], verdict['class'], watcher.r.now()), watcher.chain)
|
|
125
|
+
fields.update(chain_fields(watcher, avoid), cross=token)
|
|
126
|
+
if watcher.provider == 'codex' and watcher.st.get('funding_tier') == 'credits':
|
|
127
|
+
fields.update(funding_tier='credits', funding_pid=str(watcher.pid))
|
|
128
|
+
target_fields = dict(fields, provider=plan['provider'], acct='', avoid=plan['avoid'], cross='',
|
|
129
|
+
handoff_token=token, funding_tier='', funding_pid='', rollout_cursor='')
|
|
130
|
+
target_dir = os.path.join(plan['root'], 'tmp', 'autoresume')
|
|
131
|
+
paths = []
|
|
132
|
+
try:
|
|
133
|
+
target_paths = write_relaunch(target_dir, token, target_fields, plan['argv'])
|
|
134
|
+
paths.extend(target_paths)
|
|
135
|
+
source_paths = write_relaunch(watcher.ar_dir, token, fields, source_argv)
|
|
136
|
+
paths = list(source_paths) + paths
|
|
137
|
+
paths.append(save_dispatch(watcher, plan, token))
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
remove_quietly(*paths)
|
|
140
|
+
raise
|
|
141
|
+
keys = send_keys_argvs(tmux_socket(watcher.st['tmux']), watcher.st['pane'], watcher.provider,
|
|
142
|
+
token, watcher.sid, watcher.st['self'], watcher.root)
|
|
143
|
+
return keys, paths
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Single-use source-to-target dispatch and target startup identity; no transcript text is logged."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
11
|
+
from autoresume_probe import cross_env, handoff_settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def save_dispatch(watcher, plan, token):
|
|
15
|
+
from autoresume import _atomic_write
|
|
16
|
+
path = os.path.join(watcher.ar_dir, 'r-' + token + '.cross.json')
|
|
17
|
+
payload = {'v': 1, 'token': token, 'source_provider': watcher.provider,
|
|
18
|
+
'source_root': os.path.abspath(watcher.st['acc_root']), 'sid': watcher.sid,
|
|
19
|
+
'target_provider': plan['provider'], 'target_root': plan['root'],
|
|
20
|
+
'target_shim': plan['shim'], 'roots': plan['roots'], 'cwd': plan['history']['cwd'],
|
|
21
|
+
'settings': handoff_settings(watcher.env, plan['roots'])}
|
|
22
|
+
_atomic_write(path, json.dumps(payload, sort_keys=True).encode())
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_dispatch(root, token):
|
|
27
|
+
from autoresume import TOKEN_RE, typeable, uuid_ok
|
|
28
|
+
if not TOKEN_RE.fullmatch(token):
|
|
29
|
+
raise ValueError('invalid handoff token')
|
|
30
|
+
path = os.path.join(root, 'tmp', 'autoresume', 'r-' + token + '.cross.json')
|
|
31
|
+
info = os.lstat(path)
|
|
32
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077 \
|
|
33
|
+
or not 0 <= time.time() - info.st_mtime <= 600 or info.st_size > 65536:
|
|
34
|
+
raise ValueError('invalid handoff payload identity')
|
|
35
|
+
with open(path, encoding='utf-8') as stream:
|
|
36
|
+
payload = json.load(stream)
|
|
37
|
+
source, target = payload.get('source_provider'), payload.get('target_provider')
|
|
38
|
+
if payload.get('v') != 1 or payload.get('token') != token or source not in ('claude', 'codex') \
|
|
39
|
+
or target not in ('claude', 'codex') or source == target or not uuid_ok(payload.get('sid')):
|
|
40
|
+
raise ValueError('invalid handoff payload')
|
|
41
|
+
if os.path.abspath(root) != payload.get('source_root') or not typeable(payload.get('target_root')):
|
|
42
|
+
raise ValueError('handoff pool changed')
|
|
43
|
+
expected = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', target)
|
|
44
|
+
if os.path.realpath(payload.get('target_shim', '')) != os.path.realpath(expected):
|
|
45
|
+
raise ValueError('handoff target is not this installation')
|
|
46
|
+
roots = payload.get('roots', {})
|
|
47
|
+
if set(roots) != {'claude', 'codex'} or roots[source] != os.path.abspath(root) \
|
|
48
|
+
or roots[target] != payload['target_root'] or not all(typeable(value) for value in roots.values()):
|
|
49
|
+
raise ValueError('invalid handoff pool roots')
|
|
50
|
+
return path, payload
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def dispatch(root, token):
|
|
54
|
+
path, payload = read_dispatch(root, token)
|
|
55
|
+
from autoresume import remove_quietly
|
|
56
|
+
env = cross_env(os.environ, payload['roots'])
|
|
57
|
+
settings = handoff_settings(payload.get('settings', {}), payload['roots'])
|
|
58
|
+
env.update(settings)
|
|
59
|
+
target = payload['target_provider']
|
|
60
|
+
up = target.upper()
|
|
61
|
+
if env.get(up + '_MULTIACC_AUTORESUME', '1').lower() in ('0', 'false', 'no', 'off') \
|
|
62
|
+
or env.get(up + '_MULTIACC_DISABLE', '0') == '1' \
|
|
63
|
+
or os.path.exists(os.path.join(payload['target_root'], 'autoresume.off')):
|
|
64
|
+
raise ValueError('handoff target disabled')
|
|
65
|
+
claimed = path + '.used'
|
|
66
|
+
os.rename(path, claimed)
|
|
67
|
+
try:
|
|
68
|
+
env[up + '_MULTIACC_AR'] = token + ':' + payload['sid']
|
|
69
|
+
os.chdir(payload['cwd'])
|
|
70
|
+
remove_quietly(claimed)
|
|
71
|
+
os.execve(payload['target_shim'], [payload['target_shim']], env)
|
|
72
|
+
finally:
|
|
73
|
+
remove_quietly(claimed)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def startup(state_path, token):
|
|
77
|
+
from autoresume import TOKEN_RE, Runner, _atomic_write, load_state
|
|
78
|
+
if not TOKEN_RE.fullmatch(token):
|
|
79
|
+
raise ValueError('invalid startup token')
|
|
80
|
+
state = load_state(state_path)
|
|
81
|
+
if not state or state.get('handoff_token') != token:
|
|
82
|
+
raise ValueError('startup state is not bound to the handoff token')
|
|
83
|
+
started = Runner().lstart(state['pid'])
|
|
84
|
+
if not started:
|
|
85
|
+
raise ValueError('startup process vanished')
|
|
86
|
+
receipt = {key: state[key] for key in ('provider', 'pid', 'acct', 'pane', 'ppid', 'chain', 'depth', 'launched')}
|
|
87
|
+
receipt.update(token=token, state=os.path.abspath(state_path), started=started)
|
|
88
|
+
path = os.path.join(os.path.dirname(state_path), 'h-' + token + '.start.json')
|
|
89
|
+
_atomic_write(path, json.dumps(receipt, sort_keys=True).encode())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main():
|
|
93
|
+
parser = argparse.ArgumentParser()
|
|
94
|
+
commands = parser.add_subparsers(dest='command', required=True)
|
|
95
|
+
sub = commands.add_parser('dispatch')
|
|
96
|
+
sub.add_argument('--root', required=True)
|
|
97
|
+
sub.add_argument('--token', required=True)
|
|
98
|
+
sub = commands.add_parser('startup')
|
|
99
|
+
sub.add_argument('--state', required=True)
|
|
100
|
+
sub.add_argument('--token', required=True)
|
|
101
|
+
args = parser.parse_args()
|
|
102
|
+
try:
|
|
103
|
+
if args.command == 'dispatch':
|
|
104
|
+
dispatch(args.root, args.token)
|
|
105
|
+
else:
|
|
106
|
+
startup(args.state, args.token)
|
|
107
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
108
|
+
print('claude-multiacc: cross-provider handoff could not start safely', file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == '__main__':
|
|
114
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""A handoff succeeds only when its new client persists the exact handoff user prompt."""
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
|
|
8
|
+
from autoresume_receipt import codex_prompt_record
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_start(watcher, token, plan):
|
|
12
|
+
from autoresume import load_state
|
|
13
|
+
directory = os.path.join(plan['root'], 'tmp', 'autoresume')
|
|
14
|
+
path = os.path.join(directory, 'h-' + token + '.start.json')
|
|
15
|
+
try:
|
|
16
|
+
info = os.lstat(path)
|
|
17
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
18
|
+
return None
|
|
19
|
+
with open(path, encoding='utf-8') as stream:
|
|
20
|
+
receipt = json.load(stream)
|
|
21
|
+
state_path = receipt.get('state', '')
|
|
22
|
+
if os.path.dirname(state_path) != directory or not state_path.endswith('.state'):
|
|
23
|
+
return None
|
|
24
|
+
state = load_state(state_path)
|
|
25
|
+
if not state or receipt.get('token') != token or state.get('handoff_token') != token:
|
|
26
|
+
return None
|
|
27
|
+
for key in ('provider', 'pid', 'acct', 'pane', 'ppid', 'chain', 'depth', 'launched'):
|
|
28
|
+
if receipt.get(key) != state[key]:
|
|
29
|
+
return None
|
|
30
|
+
if state['provider'] != plan['provider'] or state['acc_root'] != plan['root'] \
|
|
31
|
+
or state['chain'] != watcher.chain or state['depth'] != int(watcher.st['depth']) + 1 \
|
|
32
|
+
or state['pane'] != watcher.st['pane'] or state['ppid'] != watcher.st['ppid']:
|
|
33
|
+
return None
|
|
34
|
+
if os.path.realpath(state['acct_dir']) != os.path.realpath(os.path.join(plan['root'], state['acct'])) \
|
|
35
|
+
or os.path.realpath(state['self']) != os.path.realpath(plan['shim']) \
|
|
36
|
+
or os.path.realpath(state['cwd']) != plan['history']['cwd'] \
|
|
37
|
+
or state['tmux'] != watcher.st['tmux']:
|
|
38
|
+
return None
|
|
39
|
+
if os.path.basename(state_path) != str(state['pid']) + '.state' \
|
|
40
|
+
or not isinstance(receipt.get('started'), str) or not receipt['started'] \
|
|
41
|
+
or watcher.r.gone(state['pid']) or watcher.r.lstart(state['pid']) != receipt['started']:
|
|
42
|
+
return None
|
|
43
|
+
return receipt, state
|
|
44
|
+
except (OSError, ValueError, TypeError, AttributeError, KeyError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def target_history(watcher, receipt, state, plan):
|
|
49
|
+
from autoresume import iso_to_epoch, rollout_meta, uuid_ok
|
|
50
|
+
directory = os.path.dirname(receipt['state'])
|
|
51
|
+
if state['provider'] == 'codex':
|
|
52
|
+
try:
|
|
53
|
+
with open(os.path.join(directory, str(state['pid']) + '.rollout'), encoding='utf-8') as stream:
|
|
54
|
+
path = stream.read(16384).strip()
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
meta = rollout_meta(path)
|
|
58
|
+
if not meta or os.path.commonpath((os.path.realpath(path), os.path.realpath(
|
|
59
|
+
os.path.join(state['acct_dir'], 'sessions')))) != os.path.realpath(
|
|
60
|
+
os.path.join(state['acct_dir'], 'sessions')):
|
|
61
|
+
return None
|
|
62
|
+
stamp = iso_to_epoch(meta.get('timestamp'))
|
|
63
|
+
if stamp is None or stamp < state['launched'] - 2:
|
|
64
|
+
return None
|
|
65
|
+
return path, meta['id']
|
|
66
|
+
try:
|
|
67
|
+
path = os.path.join(state['acct_dir'], 'sessions', str(state['pid']) + '.json')
|
|
68
|
+
with open(path, encoding='utf-8') as stream:
|
|
69
|
+
record = json.load(stream)
|
|
70
|
+
started = record.get('startedAt')
|
|
71
|
+
if record.get('pid') != state['pid'] or not isinstance(started, (float, int)) \
|
|
72
|
+
or started / 1000 < state['launched'] - 2 or not uuid_ok(record.get('sessionId')):
|
|
73
|
+
return None
|
|
74
|
+
if os.path.realpath(record.get('cwd', '')) != plan['history']['cwd']:
|
|
75
|
+
return None
|
|
76
|
+
paths = glob.glob(os.path.join(state['acct_dir'], 'projects', '*', record['sessionId'] + '.jsonl'))
|
|
77
|
+
return (paths[0], record['sessionId']) if len(paths) == 1 else None
|
|
78
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def claude_prompt_record(raw, prompt, sid):
|
|
83
|
+
try:
|
|
84
|
+
record = json.loads(raw)
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
return False
|
|
87
|
+
if not isinstance(record, dict) or record.get('type') != 'user' or record.get('sessionId') != sid \
|
|
88
|
+
or record.get('isSidechain') is True or record.get('agentId'):
|
|
89
|
+
return False
|
|
90
|
+
message = record.get('message')
|
|
91
|
+
if not isinstance(message, dict) or message.get('role') != 'user':
|
|
92
|
+
return False
|
|
93
|
+
content = message.get('content')
|
|
94
|
+
if isinstance(content, str):
|
|
95
|
+
return content == prompt
|
|
96
|
+
return isinstance(content, list) and any(isinstance(block, dict) and block.get('type') == 'text'
|
|
97
|
+
and block.get('text') == prompt for block in content)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unchanged_tail(tail, inode):
|
|
101
|
+
try:
|
|
102
|
+
current = os.stat(tail.path)
|
|
103
|
+
return current.st_ino == inode and current.st_size >= tail.off
|
|
104
|
+
except OSError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def confirm_cross(watcher, token, plan):
|
|
109
|
+
from autoresume import JsonlTail, iso_to_epoch
|
|
110
|
+
from autoresume_handoff import validate_source
|
|
111
|
+
deadline = watcher.r.now() + watcher.cfg.prompt_wait
|
|
112
|
+
tail, bound = None, None
|
|
113
|
+
while True:
|
|
114
|
+
started = read_start(watcher, token, plan)
|
|
115
|
+
history = target_history(watcher, *started, plan) if started else None
|
|
116
|
+
if history and history[1] != watcher.sid:
|
|
117
|
+
path, sid = history
|
|
118
|
+
validated = validate_source(plan['provider'], sid, path, plan['history']['cwd'])
|
|
119
|
+
if validated and bound is None:
|
|
120
|
+
bound = (path, sid, validated['inode'], started[0]['started'])
|
|
121
|
+
tail = JsonlTail(path)
|
|
122
|
+
if validated and bound == (path, sid, validated['inode'], started[0]['started']):
|
|
123
|
+
if not unchanged_tail(tail, bound[2]):
|
|
124
|
+
return None
|
|
125
|
+
for raw in tail.read_lines():
|
|
126
|
+
try:
|
|
127
|
+
stamp = iso_to_epoch(json.loads(raw).get('timestamp'))
|
|
128
|
+
except (ValueError, TypeError, AttributeError):
|
|
129
|
+
continue
|
|
130
|
+
if stamp is None or stamp < started[1]['launched'] - 2:
|
|
131
|
+
continue
|
|
132
|
+
matched = (codex_prompt_record(raw, plan['argv'][-1]) if plan['provider'] == 'codex'
|
|
133
|
+
else claude_prompt_record(raw, plan['argv'][-1], sid))
|
|
134
|
+
if matched and unchanged_tail(tail, bound[2]) and read_start(watcher, token, plan):
|
|
135
|
+
return sid
|
|
136
|
+
if watcher.switched_off() or watcher.r.now() >= deadline:
|
|
137
|
+
return None
|
|
138
|
+
watcher.r.sleep(min(0.2, watcher.cfg.poll))
|