claude-multiacc 2.0.53 → 2.0.55

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 (74) hide show
  1. package/README.md +5 -3
  2. package/bin/codex +26 -5
  3. package/docs/AUTORESUME.md +21 -6
  4. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  5. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  29. package/lib/autoresume.py +22 -5
  30. package/lib/autoresume_receipt.py +78 -0
  31. package/lib/claude_pool_prepare.py +2 -0
  32. package/lib/codex_autoresume_mode.py +58 -0
  33. package/package.json +1 -1
  34. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  39. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  65. package/tests/autoresume_tmux_support.py +135 -0
  66. package/tests/fake_codex_tui.py +81 -0
  67. package/tests/run-tests.sh +10 -1
  68. package/tests/test_autoresume.py +7 -0
  69. package/tests/test_autoresume_codex_records.py +74 -0
  70. package/tests/test_autoresume_cursor.py +87 -0
  71. package/tests/test_autoresume_mode.py +89 -0
  72. package/tests/test_autoresume_receipt.py +117 -0
  73. package/tests/test_autoresume_tmux.py +100 -0
  74. package/tests/test_autoresume_tmux_modes.py +80 -0
package/README.md CHANGED
@@ -452,9 +452,10 @@ Every other launch, including `-p` and `codex exec`, runs exactly as before.
452
452
 
453
453
  **How it works.**
454
454
 
455
- - **Launch.** The shim's `exec` is unchanged. Just before it, a detached watcher starts
455
+ - **Launch.** Just before the shim's `exec`, a detached watcher starts
456
456
  (`lib/autoresume.py`, which needs `python3`). It reads that run's transcript (codex: its
457
- rollout).
457
+ rollout). Supervised Codex launches add `--no-daemon` when the installed CLI supports
458
+ it, so the client owns the task and rollout through account changes.
458
459
  - **Probe.** Once an error settles, the watcher asks the shim, in probe mode, whether
459
460
  another account has headroom, including the 90–99% fallback for quota errors. If none
460
461
  does, it **holds** and Claude's own "continuing automatically at …" carries on.
@@ -464,7 +465,8 @@ Every other launch, including `-p` and `codex exec`, runs exactly as before.
464
465
  `/limit-reset`.
465
466
  - **Resume.** The relaunched shim parks the old account with the usual markers, then runs
466
467
  normal selection minus the account it just left. The session resumes with a short
467
- prompt telling the model it was restarted and should continue.
468
+ prompt telling the model it was restarted and should continue. Codex reports a
469
+ successful switch only after that prompt appears as a new user message in its rollout.
468
470
 
469
471
  Every step appends an `autoresume <event>` line to `selection.log`.
470
472
 
package/bin/codex CHANGED
@@ -737,7 +737,7 @@ auth_dead() { expired_marked "$1"; }
737
737
  # An interactive codex that runs into a usage limit or a dead login does NOT exit: the
738
738
  # TUI stays open on "You've hit your usage limit ... try again at <date>" and waits for a
739
739
  # human. Auto-resume moves that session — same session id — onto another pooled account
740
- # without a keystroke, and it does so without touching the exec below: the real codex
740
+ # without a keystroke. A supported client uses --no-daemon; the real codex
741
741
  # still replaces this shim (same pid, same terminal), while a detached watcher
742
742
  # (lib/autoresume.py) tails the session's rollout beside it. On a limit/auth verdict the
743
743
  # watcher asks THIS shim which account a relaunch would get (probe mode), stops codex, and
@@ -747,8 +747,8 @@ auth_dead() { expired_marked "$1"; }
747
747
  # file carrying the verdict and the argv (`resume <kept opts> <sid> <prompt>`); the old
748
748
  # account is parked the way the exec retry path below parks one, and ordinary selection
749
749
  # picks the next account. bin/claude carries the same hooks; docs/AUTORESUME.md is the
750
- # reference. Fail open everywhere: nothing here may block before exec — no network, no
751
- # waiting, no python in the foreground — and every path that cannot finish falls through
750
+ # reference. Fail open everywhere: no network; the local CLI mode probe is bounded to
751
+ # two seconds, and every path that cannot finish falls through
752
752
  # to exactly today's exec. The one exception is a typed relaunch whose token is gone: it
753
753
  # names the session to resume and exits 2, because its own (empty) argv would start a
754
754
  # fresh session in the stopped one's place.
@@ -759,6 +759,8 @@ AR_HIST="" # class:epoch,... — the chain's verdicts, for the watcher'
759
759
  AR_CHAIN="" # the chain's id, as the watcher named it
760
760
  AR_ARGV=()
761
761
  AR_ORIG_ARGV=()
762
+ AR_NO_DAEMON=0
763
+ AR_ROLLOUT_CURSOR=""
762
764
  AR_TIER="" # which cut produced the pick: eligible | soft | hard (probe answer)
763
765
  AR_R_SID="" # the session a relaunch file named, for the dead-token hint
764
766
  AR_PROBE=0
@@ -922,6 +924,7 @@ ar_relaunch() { # $1 token
922
924
  chain) ar_word_ok "$v" 1 && chain="$v" ;;
923
925
  funding_tier) [ "$v" = credits ] && funding=credits ;;
924
926
  funding_pid) num_ok "$v" && funding_pid="$v" ;;
927
+ rollout_cursor) case "$v" in *[!0-9:]*|'') ;; *) AR_ROLLOUT_CURSOR="$v" ;; esac ;;
925
928
  esac
926
929
  done < "$f"
927
930
  age=$((now - $(file_mtime "$f")))
@@ -984,7 +987,7 @@ ar_argv_ok() { # the argv codex is about to get
984
987
  continue
985
988
  fi
986
989
  case "$a" in
987
- --dangerously-bypass-approvals-and-sandbox|--yolo) ;;
990
+ --dangerously-bypass-approvals-and-sandbox|--yolo|--no-daemon) ;;
988
991
  -m|--model) want=1 ;;
989
992
  --model=?*) ;;
990
993
  --last)
@@ -1069,6 +1072,20 @@ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, then the argv codex is a
1069
1072
  root="$ACC_ROOT"
1070
1073
  case "$root" in /*) ;; *) root="$PWD/$root" ;; esac
1071
1074
  ar_path_ok "$SELF" && ar_path_ok "$root" || return 1
1075
+ # A shared daemon outlives the TUI and owns its rollout. Keep supervised clients
1076
+ # local so discovery, account rotation and the resume prompt have one owner.
1077
+ local mode="" arg explicit=0
1078
+ for arg in "$@"; do [ "$arg" != --no-daemon ] || explicit=1; done
1079
+ if [ "$explicit" = 0 ] && [ -f "$SELF_DIR/../lib/codex_autoresume_mode.py" ]; then
1080
+ mode="$("$AR_PY" -I "$SELF_DIR/../lib/codex_autoresume_mode.py" "$REAL" 2>/dev/null)" || mode=""
1081
+ if [ "$mode" = --no-daemon ]; then
1082
+ if [ "${#AR_ORIG_ARGV[@]}" -gt 0 ]; then
1083
+ AR_ORIG_ARGV=(--no-daemon "${AR_ORIG_ARGV[@]}")
1084
+ else
1085
+ AR_ORIG_ARGV=(--no-daemon)
1086
+ fi
1087
+ fi
1088
+ fi
1072
1089
  [ -d "$AR_DIR" ] || mkdir -p "$AR_DIR" 2>/dev/null || return 1
1073
1090
  st="$AR_DIR/$$.state"
1074
1091
  # The ORIGINAL argv (A2), NUL-separated: the watcher rebuilds the relaunch from it.
@@ -1086,6 +1103,7 @@ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, then the argv codex is a
1086
1103
  printf 'tmux=%s\npane=%s\nself=%s\nacc_root=%s\n' "$TMUX" "$TMUX_PANE" "$SELF" "$root"
1087
1104
  printf 'depth=%s\navoid=%s\nhist=%s\nchain=%s\n' "$AR_DEPTH" "$AR_AVOID" "$AR_HIST" "$AR_CHAIN"
1088
1105
  printf 'funding_tier=%s\n' "$AR_TIER"
1106
+ printf 'rollout_cursor=%s\n' "$AR_ROLLOUT_CURSOR"
1089
1107
  } 2>/dev/null > "$st.tmp" && mv -f "$st.tmp" "$st" 2>/dev/null || {
1090
1108
  rm -f "$st.tmp" "$AR_DIR/$$.argv" 2>/dev/null
1091
1109
  return 1
@@ -1094,6 +1112,7 @@ ar_spawn_watcher() { # $1 provider, $2 picked acct dir, then the argv codex is a
1094
1112
  # and the watcher ignores the terminal's signals until its own setsid().
1095
1113
  ( trap '' INT QUIT HUP TSTP
1096
1114
  exec "$AR_PY" -I "$lib" watch --state "$st" </dev/null >/dev/null 2>&1 & ) >/dev/null 2>&1
1115
+ [ "$mode" != --no-daemon ] || AR_NO_DAEMON=1
1097
1116
  return 0
1098
1117
  }
1099
1118
 
@@ -1486,9 +1505,11 @@ share_state_index "$pick"
1486
1505
  if [ "$wants_retry" = "0" ]; then
1487
1506
  export CODEX_HOME="$pick"
1488
1507
  # Auto-resume: a gated, detached watcher beside this exec — the exec itself (same pid,
1489
- # same argv, same environment minus the auto-resume variables) is unchanged.
1508
+ # same environment minus the auto-resume variables). Supported clients own their
1509
+ # task through --no-daemon, including the continuation passed on resume.
1490
1510
  ar_spawn_watcher codex "$pick" "$@" || true
1491
1511
  ar_scrub_env
1512
+ [ "$AR_NO_DAEMON" != 1 ] || set -- --no-daemon "$@"
1492
1513
  exec "$REAL" "$@"
1493
1514
  fi
1494
1515
 
@@ -47,16 +47,23 @@ shell: runs the shim again → token → marker on the old account → normal
47
47
  the accounts this chain left → exec … --resume <sid> "<prompt>" (with a new watcher)
48
48
  ```
49
49
 
50
- 1. **The launch is unchanged.** The shim selects exactly as before. It then `exec`s the
51
- real binary with the same pid, stdin/stdout and exit-code passthrough.
50
+ 1. **The shim selects an account and supervises the client.** It `exec`s the real
51
+ binary with the same pid, stdin/stdout and exit-code passthrough.
52
+
53
+ For Codex launches that pass all watcher gates, the shim checks the real CLI's local
54
+ `--help` under a two-second timeout. If supported, it adds `--no-daemon` and preserves
55
+ that flag on resume. Current Codex otherwise runs tasks in a shared background
56
+ daemon: the TUI holds no rollout file, and reconnecting can leave the continuation
57
+ prompt unsubmitted. A supervised client needs its own task process. Older CLIs,
58
+ disabled auto-resume, and launches outside the gates keep their existing arguments.
52
59
 
53
60
  Just before the exec, it writes a small state file to `<pool>/tmp/autoresume/`: the
54
61
  pid, account, cwd, tmux pane and original argv. It then starts the watcher fully
55
62
  detached.
56
63
 
57
- Nothing on this path waits, runs python in the foreground or touches the network (a
58
- relaunch adds one short trust step, see below). A failed check means no watcher, never
59
- a failed launch.
64
+ The capability check runs locally through Python, without network access or an
65
+ account request. A failed check leaves the CLI arguments unchanged. A failed watcher
66
+ gate means no watcher, never a failed launch.
60
67
  2. **The watcher reads what the client already writes.**
61
68
  - **Claude:** `<acct>/sessions/<pid>.json` names the session. The watcher re-reads it
62
69
  every tick, so it follows `/clear` and `/resume` inside the TUI. The transcript
@@ -69,7 +76,9 @@ shell: runs the shim again → token → marker on the old account → normal
69
76
 
70
77
  In both cases:
71
78
  - Only records written after this launch count, so an old rejection in a resumed
72
- transcript never fires.
79
+ transcript never fires. An automatic Codex restart also carries the previous
80
+ rollout's inode and byte offset: the new watcher starts after those bytes, so a
81
+ quota event from the same second cannot trigger another rotation.
73
82
  - Subagent (sidechain) records are ignored.
74
83
  3. **It waits for the error to settle.**
75
84
  - **Grace period:** a verdict has to stand for 5 s.
@@ -144,6 +153,11 @@ shell: runs the shim again → token → marker on the old account → normal
144
153
  trust?" in a directory an account has never opened, even with
145
154
  `--dangerously-skip-permissions`, and nobody is there to answer.
146
155
  6. The new pick gets a watcher of its own.
156
+ 7. For Codex, the old watcher waits up to 30 seconds for the exact continuation
157
+ prompt to appear as a new user message in the same rollout. Only then does it log
158
+ `switch`. An unsubmitted prompt logs `giveup reason=prompt` and displays a resume
159
+ hint; it never sends more keys into the TUI. Historical prompts, tool output and
160
+ a replaced or truncated rollout cannot confirm a restart.
147
161
 
148
162
  A token that is missing, malformed or older than 10 minutes starts nothing. The shim
149
163
  prints `claude-multiacc: auto-resume could not continue automatically (the resume token
@@ -160,6 +174,7 @@ The relaunch starts from the original argv:
160
174
  - `--resume <sid> "<prompt>"` is appended. For codex the command becomes
161
175
  `resume <options> <sid> "<prompt>"`.
162
176
  - An explicit `--model` from the original launch is kept. No model is added otherwise.
177
+ - Codex's explicit or automatically selected `--no-daemon` is kept.
163
178
 
164
179
  The prompt is submitted automatically:
165
180
 
package/lib/autoresume.py CHANGED
@@ -4,7 +4,7 @@
4
4
  An interactive ``claude`` or ``codex`` that runs into a usage limit does NOT exit. Claude
5
5
  idles on "continuing automatically at <reset>" (which can be days away) and codex sits on
6
6
  "You've hit your usage limit ... try again at <date>", both waiting for a human. The shim
7
- (bin/claude, bin/codex) spawns this watcher, detached, right before its byte-identical
7
+ (bin/claude, bin/codex) spawns this watcher, detached, right before its
8
8
  ``exec`` of an interactive TUI inside tmux. The watcher tails that session's own
9
9
  transcript (claude) or rollout (codex) and, on a verdict it can act on:
10
10
 
@@ -58,6 +58,7 @@ import traceback
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
60
  from autoresume_probe import ACCT_RE, ROTATE, parse_probe, probe_allows, probe_env
61
+ from autoresume_receipt import apply_cursor, confirm_prompt, receipt_tail
61
62
 
62
63
  # ---- vocabulary ---------------------------------------------------------------------
63
64
 
@@ -125,9 +126,10 @@ MONTHS = {m: i for i, m in enumerate(
125
126
  ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"), 1)}
126
127
 
127
128
  STATE_KEYS = ("v", "provider", "pid", "ppid", "acct", "acct_dir", "cwd", "launched", "tmux",
128
- "pane", "self", "acc_root", "depth", "avoid", "hist", "chain", "funding_tier")
129
+ "pane", "self", "acc_root", "depth", "avoid", "hist", "chain", "funding_tier",
130
+ "rollout_cursor")
129
131
  RELAUNCH_KEYS = ("v", "provider", "class", "acct", "reset", "rtype", "marked", "sid", "cwd",
130
- "depth", "avoid", "hist", "chain", "funding_tier", "funding_pid")
132
+ "depth", "avoid", "hist", "chain", "funding_tier", "funding_pid", "rollout_cursor")
131
133
  HIST_KEEP = 32
132
134
 
133
135
  # ---- knobs --------------------------------------------------------------------------
@@ -152,6 +154,7 @@ KNOBS = {
152
154
  "SUBAGENT_QUIET": 20.0,
153
155
  "SUBAGENT_CAP": 900.0,
154
156
  "RELAUNCH_WAIT": 10.0,
157
+ "PROMPT_WAIT": 30.0,
155
158
  }
156
159
  LADDER_DEFAULT = (30.0, 60.0, 120.0)
157
160
  # After DISCOVER_TIMEOUT a codex watcher still looks for its rollout (a session first
@@ -709,7 +712,7 @@ def codex_argv_parse(argv):
709
712
  kept, resume, target, prompt, i = [], False, None, False, 0
710
713
  while i < len(argv):
711
714
  arg = argv[i]
712
- if arg in ("--dangerously-bypass-approvals-and-sandbox", "--yolo"):
715
+ if arg in ("--dangerously-bypass-approvals-and-sandbox", "--yolo", "--no-daemon"):
713
716
  kept.append(arg)
714
717
  elif arg in ("-m", "--model"):
715
718
  if i + 1 >= len(argv) or not _value_ok(argv[i + 1]):
@@ -806,7 +809,7 @@ def format_relaunch(fields):
806
809
  value carrying a line break (or NUL) cannot be written faithfully: ValueError."""
807
810
  lines = []
808
811
  for key in RELAUNCH_KEYS:
809
- if key in ("funding_tier", "funding_pid") and key not in fields:
812
+ if key in ("funding_tier", "funding_pid", "rollout_cursor") and key not in fields:
810
813
  continue
811
814
  val = "" if fields.get(key) is None else str(fields[key])
812
815
  if any(c in val for c in "\0\n\r"):
@@ -942,6 +945,7 @@ def load_state(path):
942
945
  "acc_root": acc_root, "depth": int(depth),
943
946
  "avoid": kv.get("avoid", ""), "hist": kv.get("hist", ""), "chain": chain,
944
947
  "funding_tier": "credits" if kv.get("funding_tier") == "credits" else "included",
948
+ "rollout_cursor": kv.get("rollout_cursor", ""),
945
949
  }
946
950
 
947
951
 
@@ -1619,6 +1623,9 @@ class Watcher:
1619
1623
 
1620
1624
  def attach(self, path, meta, how):
1621
1625
  self.tail = JsonlTail(path)
1626
+ if meta["id"] == codex_resume_id(self.argv) \
1627
+ and not apply_cursor(self.tail, self.st.get("rollout_cursor")):
1628
+ self.giveup("rollout-replaced", sid=meta["id"])
1622
1629
  self.transcript = path
1623
1630
  self.sid = meta["id"]
1624
1631
  mcwd = meta.get("cwd")
@@ -1956,9 +1963,16 @@ class Watcher:
1956
1963
  # 2. The relaunch file exists BEFORE the kill: a kill always has its relaunch.
1957
1964
  now = self.r.now()
1958
1965
  token = make_token()
1966
+ receipt = None
1967
+ if self.provider == "codex":
1968
+ receipt = receipt_tail(self.transcript, JsonlTail)
1969
+ if receipt is None:
1970
+ self.giveup("receipt", sid=self.sid, **{"class": cls})
1959
1971
  fields = relaunch_fields(self.provider, verdict, self.acct, self.sid, cwd,
1960
1972
  self.st["depth"], avoid,
1961
1973
  hist_append(self.st["hist"], cls, now), self.chain)
1974
+ if receipt is not None:
1975
+ fields["rollout_cursor"] = "%d:%d" % (receipt.ino, receipt.off)
1962
1976
  if self.provider == "codex" and self.st.get("funding_tier") == "credits":
1963
1977
  fields.update(funding_tier="credits", funding_pid=str(self.pid))
1964
1978
  try:
@@ -2002,6 +2016,9 @@ class Watcher:
2002
2016
  remove_quietly(*paths)
2003
2017
  self.giveup("relaunch", sid=self.sid, **{"class": cls})
2004
2018
  self.r.sleep(min(0.2, self.cfg.poll))
2019
+ # Token consumption alone does not prove Codex submitted the continuation.
2020
+ if receipt is not None and not confirm_prompt(self, receipt, argv[-1]):
2021
+ self.giveup("prompt", sid=self.sid, **{"class": cls})
2005
2022
  # 7.
2006
2023
  self.log("switch", **{"from": self.acct, "class": cls, "sid": self.sid,
2007
2024
  "depth": int(self.st["depth"]) + 1, "probe": probe_pick or "-"})
@@ -0,0 +1,78 @@
1
+ """Confirm that Codex persisted the continuation, without recording chat text."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+
7
+
8
+ def cursor_value(value):
9
+ if isinstance(value, str) and re.fullmatch(r"[0-9]{1,20}:[0-9]{1,20}", value):
10
+ return tuple(int(part) for part in value.split(":"))
11
+ return None
12
+
13
+
14
+ def apply_cursor(tail, value):
15
+ """Resume after the previous client's bytes, including when launches share a second."""
16
+ if value in (None, ""):
17
+ return True
18
+ cursor = cursor_value(value)
19
+ if cursor is None:
20
+ return False
21
+ try:
22
+ state = os.stat(tail.path)
23
+ except OSError:
24
+ return False
25
+ inode, offset = cursor
26
+ if state.st_ino != inode or state.st_size < offset:
27
+ return False
28
+ tail.ino, tail.off = inode, offset
29
+ return True
30
+
31
+
32
+ def receipt_tail(path, tail_type):
33
+ """Pin a known rollout and its pre-relaunch EOF; old messages cannot be receipts."""
34
+ try:
35
+ state = os.stat(path)
36
+ except (OSError, TypeError):
37
+ return None
38
+ tail = tail_type(path)
39
+ tail.off, tail.ino = state.st_size, state.st_ino
40
+ return tail
41
+
42
+
43
+ def codex_prompt_record(raw, prompt):
44
+ """Accept only an actual user message, never a tool result quoting the prompt."""
45
+ try:
46
+ rec = json.loads(raw)
47
+ except (ValueError, TypeError):
48
+ return False
49
+ if not isinstance(rec, dict) or not isinstance(rec.get("payload"), dict):
50
+ return False
51
+ payload = rec["payload"]
52
+ if rec.get("type") == "event_msg" and payload.get("type") == "user_message":
53
+ return payload.get("message") == prompt
54
+ if rec.get("type") != "response_item" or payload.get("type") != "message" \
55
+ or payload.get("role") != "user":
56
+ return False
57
+ content = payload.get("content")
58
+ if not isinstance(content, list):
59
+ return False
60
+ return any(isinstance(block, dict) and block.get("type") == "input_text"
61
+ and block.get("text") == prompt for block in content)
62
+
63
+
64
+ def confirm_prompt(watcher, tail, prompt):
65
+ """The relaunch token proves shell execution; a new user record proves submission."""
66
+ deadline = watcher.r.now() + watcher.cfg.prompt_wait
67
+ while True:
68
+ try:
69
+ state = os.stat(tail.path)
70
+ except OSError:
71
+ return False
72
+ if state.st_ino != tail.ino or state.st_size < tail.off:
73
+ return False
74
+ if any(codex_prompt_record(raw, prompt) for raw in tail.read_lines()):
75
+ return True
76
+ if watcher.switched_off() or watcher.r.now() >= deadline:
77
+ return False
78
+ watcher.r.sleep(min(0.2, watcher.cfg.poll))
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """Prepare one local Claude pool's shared history and interactive permission default."""
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  import argparse
5
7
  import fcntl
6
8
  import json
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Detect the local Codex mode that keeps a supervised TUI's work in its process tree.
3
+
4
+ New Codex releases default to a shared daemon, whose rollout and background work
5
+ outlive the terminal client. Only clients advertising ``--no-daemon`` receive it;
6
+ older releases keep their existing command line. Help never needs credentials or
7
+ network access. Every failure leaves the launch unchanged.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+
19
+
20
+ OPTION = re.compile(rb"(?m)^\s+--no-daemon(?:\s|$)")
21
+ PROBE_TIMEOUT = 2.0
22
+ MAX_HELP_BYTES = 1 << 20
23
+
24
+
25
+ def supports_no_daemon(executable, timeout=PROBE_TIMEOUT):
26
+ """Read bounded local help; a timed-out probe kills only the group it created."""
27
+ try:
28
+ with tempfile.TemporaryFile() as output:
29
+ process = subprocess.Popen(
30
+ [executable, "--help"], stdin=subprocess.DEVNULL, stdout=output,
31
+ stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True,
32
+ )
33
+ try:
34
+ code = process.wait(timeout=timeout)
35
+ except subprocess.TimeoutExpired:
36
+ try:
37
+ os.killpg(process.pid, signal.SIGKILL)
38
+ except ProcessLookupError:
39
+ pass
40
+ process.wait()
41
+ return False
42
+ if code != 0:
43
+ return False
44
+ output.seek(0)
45
+ return OPTION.search(output.read(MAX_HELP_BYTES)) is not None
46
+ except (OSError, ValueError, subprocess.SubprocessError):
47
+ return False
48
+
49
+
50
+ def main(argv=None):
51
+ args = sys.argv[1:] if argv is None else argv
52
+ if len(args) == 1 and supports_no_daemon(args[0]):
53
+ print("--no-daemon")
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-multiacc",
3
- "version": "2.0.53",
3
+ "version": "2.0.55",
4
4
  "description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
5
5
  "type": "module",
6
6
  "bin": {