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.
- package/README.md +5 -3
- package/bin/codex +26 -5
- package/docs/AUTORESUME.md +21 -6
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.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 +22 -5
- package/lib/autoresume_receipt.py +78 -0
- package/lib/claude_pool_prepare.py +2 -0
- package/lib/codex_autoresume_mode.py +58 -0
- package/package.json +1 -1
- 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_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.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_tmux_support.py +135 -0
- package/tests/fake_codex_tui.py +81 -0
- package/tests/run-tests.sh +10 -1
- package/tests/test_autoresume.py +7 -0
- package/tests/test_autoresume_codex_records.py +74 -0
- package/tests/test_autoresume_cursor.py +87 -0
- package/tests/test_autoresume_mode.py +89 -0
- package/tests/test_autoresume_receipt.py +117 -0
- package/tests/test_autoresume_tmux.py +100 -0
- 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.**
|
|
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
|
|
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:
|
|
751
|
-
#
|
|
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
|
|
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
|
|
package/docs/AUTORESUME.md
CHANGED
|
@@ -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
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
|
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))
|
|
@@ -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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|