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.
Files changed (106) 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 +84 -0
  5. package/docs/AUTORESUME.md +16 -5
  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 +58 -14
  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_origin.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  89. package/tests/autoresume_cross_receipt_support.py +58 -0
  90. package/tests/autoresume_cross_tmux_environment.py +59 -0
  91. package/tests/autoresume_cross_tmux_support.py +112 -0
  92. package/tests/autoresume_cross_unit_support.py +92 -0
  93. package/tests/fixtures/cross_provider_cli.py +122 -0
  94. package/tests/test_autoresume.py +3 -1
  95. package/tests/test_autoresume_cross_claude_holders.py +81 -0
  96. package/tests/test_autoresume_cross_guards.py +133 -0
  97. package/tests/test_autoresume_cross_holders.py +89 -0
  98. package/tests/test_autoresume_cross_origin.py +70 -0
  99. package/tests/test_autoresume_cross_policy.py +123 -0
  100. package/tests/test_autoresume_cross_receipt.py +141 -0
  101. package/tests/test_autoresume_cross_tmux.py +140 -0
  102. package/tests/test_autoresume_cross_tmux_races.py +45 -0
  103. package/tests/test_autoresume_cross_tokens.py +102 -0
  104. package/tests/test_autoresume_handoff_argv.py +127 -0
  105. package/tests/test_autoresume_handoff_history.py +115 -0
  106. 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 ("funding_tier", "funding_pid", "rollout_cursor") and key not in fields:
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
- if not probe_allows(cls, pick, tier, self.acct):
1779
- # Claude keeps its own auto-continue meanwhile; a cancel ends the hold.
1780
- self.hold(verdict, reason="no-room", pick=pick or "-", tier=tier or "-")
1781
- return None
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) and doc.get("sessionId") == self.sid \
1835
- and self.r.alive(int(name)):
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
- paths = write_relaunch(self.ar_dir, token, fields, argv)
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
- "depth": int(self.st["depth"]) + 1, "probe": probe_pick or "-"})
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))