claude-multiacc 2.0.57 → 2.0.59
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 +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +30 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- 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_trust.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_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.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__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +82 -10
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- 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_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.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_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.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_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.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_autoresume_trust_tmux.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/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +111 -1
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_origin.py +70 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +67 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_packaged_commands.py +41 -0
package/lib/autoresume.py
CHANGED
|
@@ -6,12 +6,15 @@ idles on "continuing automatically at <reset>" (which can be days away) and code
|
|
|
6
6
|
"You've hit your usage limit ... try again at <date>", both waiting for a human. The shim
|
|
7
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
|
-
transcript (claude
|
|
9
|
+
transcript (claude: its subagents' and workflow agents' transcripts too) or rollout (codex)
|
|
10
|
+
— a claude watcher also reads its own pane, read-only, for the client's critical-memory
|
|
11
|
+
banner, which no transcript records — and, on a verdict it can act on:
|
|
10
12
|
|
|
11
13
|
1. asks the SHIM which account a relaunch would get (probe mode: the real candidate loop,
|
|
12
14
|
``pick=<acct> tier=<t>``, never an exec) — so it only stops a session there is
|
|
13
15
|
somewhere to move to;
|
|
14
|
-
2. writes a single-use relaunch file (verdict + argv + chain state);
|
|
16
|
+
2. writes a single-use relaunch file (verdict + argv + chain state); the argv pins the model
|
|
17
|
+
and effort the session itself last ran with, read from its history right then;
|
|
15
18
|
3. stops the client with SIGTERM (SIGKILL after a grace);
|
|
16
19
|
4. waits for the pane to be back at its SHELL and types
|
|
17
20
|
`` CLAUDE_MULTIACC_AR=<token>:<sid> [CLAUDE_ACCOUNTS_ROOT=<root>] <shim path>`` there.
|
|
@@ -49,11 +52,13 @@ import shutil
|
|
|
49
52
|
import signal
|
|
50
53
|
import stat
|
|
51
54
|
import string
|
|
55
|
+
import struct
|
|
52
56
|
import subprocess
|
|
53
57
|
import sys
|
|
54
58
|
import tempfile
|
|
55
59
|
import time
|
|
56
60
|
import traceback
|
|
61
|
+
from collections import deque
|
|
57
62
|
|
|
58
63
|
# -I omits the script directory; load only helpers from this installation.
|
|
59
64
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
@@ -61,6 +66,9 @@ from autoresume_probe import ACCT_RE, ROTATE, parse_probe, probe_allows, probe_e
|
|
|
61
66
|
from autoresume_receipt import apply_cursor, confirm_prompt, receipt_tail
|
|
62
67
|
from autoresume_cross import chain_fields, plan_fallback, prepare as prepare_cross
|
|
63
68
|
from autoresume_cross_receipt import confirm_cross
|
|
69
|
+
from model_catalog import load as load_catalog, parse_id
|
|
70
|
+
from model_match import resume_settings, settings_flags
|
|
71
|
+
from session_settings import session_settings
|
|
64
72
|
|
|
65
73
|
# ---- vocabulary ---------------------------------------------------------------------
|
|
66
74
|
|
|
@@ -84,6 +92,7 @@ REASONS = {
|
|
|
84
92
|
"blocked": "the previous account was refused",
|
|
85
93
|
"transient": "an API error ended the last turn",
|
|
86
94
|
"crash": "the previous process exited unexpectedly",
|
|
95
|
+
"memory": "the previous process reached critical memory usage",
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
# How long the chain keeps away from the account it is leaving. quota uses the server's
|
|
@@ -92,6 +101,10 @@ AVOID_SECONDS = {"auth": 3600, "blocked": 6 * 3600, "model": 5 * 3600}
|
|
|
92
101
|
QUOTA_FALLBACK = 3600
|
|
93
102
|
|
|
94
103
|
CLAUDE_QUOTA_TYPES = ("five_hour", "seven_day")
|
|
104
|
+
# "concurrents": the account turned away a request among too many in flight at once. A
|
|
105
|
+
# moment's throttle, not one model's bucket: on 2026-10-20 acct-02 refused 54 so between
|
|
106
|
+
# 12:36:50 and 12:39:44 while serving 700+ requests a minute, so the next one goes through.
|
|
107
|
+
CLAUDE_THROTTLE_TYPES = ("concurrents",)
|
|
95
108
|
CLAUDE_TRANSIENT = ("overloaded", "server_error", "unknown")
|
|
96
109
|
CLAUDE_BLOCKED = ("oauth_org_not_allowed", "account_on_hold", "billing_error",
|
|
97
110
|
"verification_required")
|
|
@@ -100,6 +113,24 @@ CLAUDE_BLOCKED = ("oauth_org_not_allowed", "account_on_hold", "billing_error",
|
|
|
100
113
|
MODEL_TEXT = re.compile(r"reached your .{0,40} limit|switch to another model", re.I)
|
|
101
114
|
# User records that are machinery, not a human at the keyboard.
|
|
102
115
|
NON_HUMAN_PREFIXES = ("<task-notification", "<local-command", "<system-reminder")
|
|
116
|
+
# A slash command as the client records it: a user record whose text starts with one of
|
|
117
|
+
# these, naming the command in <command-name>/model</command-name>. A human at the keyboard,
|
|
118
|
+
# but one running a command that only shows or configures the session (INERT_COMMANDS)
|
|
119
|
+
# handles a model's limit or a failed turn that way (/model), never the account's: that is
|
|
120
|
+
# just as limited afterwards (sessions 5 and 8 ran /model, 2026-10-19). Any other command
|
|
121
|
+
# may be what fixed the account (/login, /limit-reset, /usage-credits, /upgrade, /config's
|
|
122
|
+
# settings ...): it counts as the user typing. Names and aliases as claude 2.1.303 has them.
|
|
123
|
+
COMMAND_PREFIXES = ("<command-name>", "<command-message>")
|
|
124
|
+
COMMAND_NAME_RE = re.compile(r"<command-name>\s*/?([A-Za-z0-9:._-]{1,64})\s*</command-name>")
|
|
125
|
+
INERT_COMMANDS = frozenset((
|
|
126
|
+
"model", "effort", "usage", "cost", "stats", "status", "context", "help", "mcp", "plugin",
|
|
127
|
+
"plugins", "reload-plugins", "doctor", "memory", "permissions", "agents", "hooks", "ide",
|
|
128
|
+
"skills", "theme", "statusline", "output-style", "keybindings", "vim", "terminal-setup",
|
|
129
|
+
"export", "copy", "todos", "tasks", "bashes", "add-dir", "release-notes"))
|
|
130
|
+
# Verdicts about the whole ACCOUNT, not one model or one turn: the only ones a subagent's or
|
|
131
|
+
# workflow agent's transcript can report for the session, and the ones no inert slash
|
|
132
|
+
# command ends.
|
|
133
|
+
ACCOUNT_CLASSES = ("quota", "auth", "blocked")
|
|
103
134
|
|
|
104
135
|
CODEX_QUOTA = ("usage_limit_exceeded", "rate_limit_exceeded")
|
|
105
136
|
CODEX_AUTH = ("unauthorized",)
|
|
@@ -119,6 +150,14 @@ TOKEN_RE = re.compile(r"^[A-Za-z0-9]{8,64}$")
|
|
|
119
150
|
CLASS_RE = re.compile(r"^[a-z_]{1,16}$")
|
|
120
151
|
# What the relaunch line may carry unquoted (the shims' gate refuses anything else).
|
|
121
152
|
TYPEABLE_RE = re.compile(r"^/[A-Za-z0-9/._+-]*$")
|
|
153
|
+
# What a relaunch may pin from the session's own history: a model id (context suffixes like
|
|
154
|
+
# [1m] and provider-qualified ids included) and an effort level — [a-z]+ is also what
|
|
155
|
+
# bin/codex's gate accepts after `-c model_reasoning_effort=`.
|
|
156
|
+
MODEL_VALUE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+\[\]-]{0,199}\Z")
|
|
157
|
+
EFFORT_VALUE_RE = re.compile(r"^[a-z]+\Z")
|
|
158
|
+
CODEX_EFFORT_KEY = "model_reasoning_effort="
|
|
159
|
+
CLAUDE_FAMILY_ALIASES = ("sonnet", "opus", "haiku", "fable")
|
|
160
|
+
CONTEXT_SUFFIX_RE = re.compile(r"\[\d+[km]\]$", re.I)
|
|
122
161
|
ISO_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?"
|
|
123
162
|
r"(Z|[+-]\d{2}:?\d{2})?$")
|
|
124
163
|
TRY_AGAIN_RE = re.compile(
|
|
@@ -127,20 +166,85 @@ TRY_AGAIN_RE = re.compile(
|
|
|
127
166
|
MONTHS = {m: i for i, m in enumerate(
|
|
128
167
|
("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"), 1)}
|
|
129
168
|
|
|
169
|
+
# claude 2.1.304 reads its heap every 10 s (process.memoryUsage().heapUsed), and from 2.5 GiB
|
|
170
|
+
# on shows, red, in the notification column of the prompt's footer: at the right of the mode
|
|
171
|
+
# line when the row has room, on the footer's next row (the column wraps) when it has not,
|
|
172
|
+
# the text itself cut with an ellipsis, never wrapped:
|
|
173
|
+
# Critical memory usage (8.3GB) — restart and resume with claude --continue, or run /compact
|
|
174
|
+
# It writes nothing of it to the transcript. The size is the client's own format (one
|
|
175
|
+
# decimal, ".0" dropped). After it comes MEMORY_TAIL with its em dash, whole, or as much of it
|
|
176
|
+
# as fits and the ellipsis Ink's truncation ends it with -- a hyphen or an en dash is never the
|
|
177
|
+
# client's. The banner is the last line of the notification column, so nothing follows it on
|
|
178
|
+
# its row but what the client draws right of that column (MEMORY_AFTER_RE). Nothing is
|
|
179
|
+
# required before it: in a pane too narrow for the row, the column it is in can abut the mode
|
|
180
|
+
# line, cut to an ellipsis of its own.
|
|
181
|
+
MEMORY_BANNER_RE = re.compile(r"Critical memory usage \((\d{1,7}(?:\.\d)?(?:KB|MB|GB)"
|
|
182
|
+
r"|\d{1,10} bytes)\)")
|
|
183
|
+
MEMORY_TAIL = " — restart and resume with claude --continue, or run /compact"
|
|
184
|
+
MEMORY_TAIL_MIN = len(" — restart and resume with claude --continue") # ", or run…" is optional
|
|
185
|
+
# Right of the notification column, on its last row, 2.1.304 draws only a stashed prompt's pill
|
|
186
|
+
# (" · › stashed", or " › stashed" when no notification is up) and an active goal's ("◎ /goal
|
|
187
|
+
# active (5m)", " · " before it after any other item), each only while there is one; then the
|
|
188
|
+
# row ends.
|
|
189
|
+
MEMORY_AFTER_RE = re.compile(r"(?:(?: · | )› stashed)?(?:(?: · )?◎ /goal active"
|
|
190
|
+
r"(?: \([^()]{1,16}\))?)?")
|
|
191
|
+
# A row of the background-task list under the footer (2.1.304's, after a blank row): the
|
|
192
|
+
# selection pointer (❯, > where it cannot be drawn) or two spaces, an agent's tree connector
|
|
193
|
+
# (├ └, two columns further in a level), then the task's bullet -- ◯ (( ) where it cannot be
|
|
194
|
+
# drawn), ⏺ or ● for the one viewed, ⏸ for a paused workflow -- and a space. Its name and
|
|
195
|
+
# description are the model's own words, so such a row is never read for the banner -- but
|
|
196
|
+
# for the footer's first row, which is always the client's own (its status line, whatever that
|
|
197
|
+
# starts with, or its mode line): the task list comes after the footer.
|
|
198
|
+
TASK_ROW_RE = re.compile(r"(?: |[❯>] )(?:(?: )*[├└] )?(?:[◯⏺●⏸]|\( \)) ")
|
|
199
|
+
# The prompt's box, the one place on screen the client's own status hangs from (2.1.304,
|
|
200
|
+
# inline: the pool's accounts run with "tui": "default"): a rule across the pane from its first
|
|
201
|
+
# column; right under it the prompt's row, the client's mode indicator in that first column
|
|
202
|
+
# (❯, ! in shell mode, > where ❯ cannot be drawn, or the agent a teammate view names) and its
|
|
203
|
+
# no-break space; the input's other rows, indented past the indicator; the rule closing it,
|
|
204
|
+
# from the first column again. The footer is the rows right under that rule, down to the first
|
|
205
|
+
# blank row (the background-task list starts after one). A dialog drawn in the box's place is
|
|
206
|
+
# framed by one rule on top, its title and body indented under it: no prompt row, so no box,
|
|
207
|
+
# and nothing it quotes counts. Neither does anything above the box (the conversation) or in
|
|
208
|
+
# it (what the user is typing).
|
|
209
|
+
RULE_RE = re.compile(r"[─━]{8,}")
|
|
210
|
+
PROMPT_ROW_RE = re.compile(r"[^\s\xa0][^\xa0]{0,24}\xa0")
|
|
211
|
+
# How far under the prompt's row the rule closing the box may be (the draft's rows): a box
|
|
212
|
+
# holding a taller draft is not read.
|
|
213
|
+
PROMPT_ROWS = 40
|
|
214
|
+
# How far above the box the compaction spinner is looked for (it sits right above the box,
|
|
215
|
+
# under the todo list when one is shown).
|
|
216
|
+
MEMORY_ROWS = 40
|
|
217
|
+
# The client's spinner above the prompt's box while it compacts the conversation (2.1.304:
|
|
218
|
+
# "Compacting conversation…"): /compact, the banner's other remedy, under way. The transcript
|
|
219
|
+
# shows nothing of it until it is done (a compact_boundary record).
|
|
220
|
+
COMPACTING = "Compacting conversation"
|
|
221
|
+
# capture-pane -p prints the screen's text alone, but a stray sequence (or a fake) is no reason
|
|
222
|
+
# to miss the banner: escape sequences and C0/C1 controls are dropped before matching.
|
|
223
|
+
ANSI_RE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])")
|
|
224
|
+
CONTROL_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
225
|
+
|
|
130
226
|
STATE_KEYS = ("v", "provider", "pid", "ppid", "acct", "acct_dir", "cwd", "launched", "tmux",
|
|
131
227
|
"pane", "self", "acc_root", "depth", "avoid", "hist", "chain", "funding_tier",
|
|
132
|
-
"rollout_cursor", "avoid_claude", "avoid_codex", "pool_claude", "pool_codex", "handoff_token"
|
|
228
|
+
"rollout_cursor", "avoid_claude", "avoid_codex", "pool_claude", "pool_codex", "handoff_token",
|
|
229
|
+
"via")
|
|
133
230
|
RELAUNCH_KEYS = ("v", "provider", "class", "acct", "reset", "rtype", "marked", "sid", "cwd",
|
|
134
231
|
"depth", "avoid", "hist", "chain", "funding_tier", "funding_pid", "rollout_cursor",
|
|
135
|
-
"avoid_claude", "avoid_codex", "pool_claude", "pool_codex", "handoff_token", "cross"
|
|
232
|
+
"avoid_claude", "avoid_codex", "pool_claude", "pool_codex", "handoff_token", "cross",
|
|
233
|
+
"model", "via")
|
|
136
234
|
HIST_KEEP = 32
|
|
137
235
|
|
|
138
236
|
# ---- knobs --------------------------------------------------------------------------
|
|
139
237
|
# Every timing constant is overridable as <P>_MULTIACC_AR_<NAME> (P = CLAUDE or CODEX) so
|
|
140
|
-
# the suites can run the whole stop/relaunch sequence in a second. The last
|
|
238
|
+
# the suites can run the whole stop/relaunch sequence in a second. The last ones are not
|
|
141
239
|
# in the spec's list; they bound waits the spec fixes (probe 30 s, rollout discovery
|
|
142
|
-
# 5 min, subagent quiet 20 s capped at 15 min, the typed relaunch taking effect 10 s)
|
|
143
|
-
# exist for the same reason.
|
|
240
|
+
# 5 min, subagent quiet 20 s capped at 15 min, the typed relaunch taking effect 10 s), or
|
|
241
|
+
# pace the look for new subagent transcripts (5 s), and exist for the same reason. The
|
|
242
|
+
# MEMORY_* ones pace and bound the claude watcher's look at its pane for the client's
|
|
243
|
+
# critical-memory banner (watch_memory): a read every MEMORY_SCAN s (0 turns it off), none
|
|
244
|
+
# before the launch is MEMORY_MIN_UPTIME old, the main thread's work waited for up to
|
|
245
|
+
# MEMORY_BUSY_CAP, at most MEMORY_PER_HOUR such relaunches a chain an hour, none for
|
|
246
|
+
# MEMORY_BACKOFF s after a check before the stop refused one (refuse), and -- only when set --
|
|
247
|
+
# the client's own memory at MEMORY_LIMIT_MB or more counting as well.
|
|
144
248
|
KNOBS = {
|
|
145
249
|
"POLL": 1.0,
|
|
146
250
|
"GRACE": 5.0,
|
|
@@ -158,6 +262,13 @@ KNOBS = {
|
|
|
158
262
|
"SUBAGENT_CAP": 900.0,
|
|
159
263
|
"RELAUNCH_WAIT": 10.0,
|
|
160
264
|
"PROMPT_WAIT": 30.0,
|
|
265
|
+
"SUBAGENT_SCAN": 5.0,
|
|
266
|
+
"MEMORY_SCAN": 30.0,
|
|
267
|
+
"MEMORY_MIN_UPTIME": 900.0,
|
|
268
|
+
"MEMORY_BUSY_CAP": 600.0,
|
|
269
|
+
"MEMORY_PER_HOUR": 2,
|
|
270
|
+
"MEMORY_BACKOFF": 600.0,
|
|
271
|
+
"MEMORY_LIMIT_MB": 0,
|
|
161
272
|
}
|
|
162
273
|
LADDER_DEFAULT = (30.0, 60.0, 120.0)
|
|
163
274
|
# After DISCOVER_TIMEOUT a codex watcher still looks for its rollout (a session first
|
|
@@ -170,6 +281,26 @@ MAX_TREE = 64 # more "descendants" than this is not one TUI's tree: kil
|
|
|
170
281
|
# The cwd fallback is for a machine that cannot list open files; a lookup that is there
|
|
171
282
|
# but has failed this long counts as not there.
|
|
172
283
|
LOOKUP_FAILED = 30.0
|
|
284
|
+
# A claude session's subagents (the Agent tool) and workflow agents write transcripts of
|
|
285
|
+
# their own (<sid>/subagents/**/agent-*.jsonl), and a limit that stops them may be recorded
|
|
286
|
+
# nowhere else (sessions 5 and 8, 2026-10-19). They are looked for every SUBAGENT_SCAN s, in
|
|
287
|
+
# a walk of at most SIDE_SCAN entries. Only the SIDE_FILES written most recently are
|
|
288
|
+
# followed, and they share SIDE_BYTES per tick: a session with hundreds of agents, some past
|
|
289
|
+
# 15 MB, cannot stall the one-second loop.
|
|
290
|
+
SIDE_SCAN = 5000
|
|
291
|
+
SIDE_FILES = 128
|
|
292
|
+
SIDE_BYTES = 16 << 20
|
|
293
|
+
# One last written this long before the launch holds nothing of it: followed from its end.
|
|
294
|
+
SIDE_SLACK = 5.0
|
|
295
|
+
# A subagent's rejection counts only this long after the tracker's `since` (the launch less
|
|
296
|
+
# the main thread's two seconds of slack): from a second past the launch `date +%s` floored.
|
|
297
|
+
# A relaunch replaces a process whose workflow agents met the limit in bursts that span
|
|
298
|
+
# seconds, up to the moment it was gone, just before this launch; this launch's own agents
|
|
299
|
+
# start only after its first request.
|
|
300
|
+
SIDE_AFTER = 3.0
|
|
301
|
+
# The newest main-transcript lines a claude watcher keeps (main_thread_busy reads them): an
|
|
302
|
+
# agent's verdict waits while the main thread's own request, stream or tool is in flight.
|
|
303
|
+
MAIN_RECENT_BYTES = 4 << 20
|
|
173
304
|
|
|
174
305
|
|
|
175
306
|
def _knob(raw, default):
|
|
@@ -349,6 +480,10 @@ def budget_check(cls, depth, hist, now, cfg):
|
|
|
349
480
|
used = sum(1 for c, t in entries if c == "crash" and now - t < 600)
|
|
350
481
|
if used >= cfg.crash_per_10min:
|
|
351
482
|
return False, "crash-budget"
|
|
483
|
+
elif cls == "memory":
|
|
484
|
+
used = sum(1 for c, t in entries if c == "memory" and now - t < 3600)
|
|
485
|
+
if used >= cfg.memory_per_hour:
|
|
486
|
+
return False, "memory-budget"
|
|
352
487
|
return True, ""
|
|
353
488
|
|
|
354
489
|
|
|
@@ -383,28 +518,34 @@ def _message_text(rec):
|
|
|
383
518
|
|
|
384
519
|
|
|
385
520
|
def claude_human_input(rec, prefixes=(PROMPT_PREFIX,)):
|
|
386
|
-
"""
|
|
387
|
-
|
|
521
|
+
"""What a main-thread user record a HUMAN produced is: "command" for a slash command
|
|
522
|
+
that only shows or configures the session (INERT_COMMANDS: /model, /usage ...), "user"
|
|
523
|
+
for anything else typed or pasted (text, an image, any other command: /login,
|
|
524
|
+
/limit-reset ...). None for a tool result, a meta/compaction record, a harness
|
|
388
525
|
notification, or the watcher's own auto-resume prompt."""
|
|
389
526
|
if rec.get("isMeta") is True or rec.get("isCompactSummary") is True \
|
|
390
527
|
or rec.get("isVisibleInTranscriptOnly") is True or "toolUseResult" in rec:
|
|
391
|
-
return
|
|
528
|
+
return None
|
|
392
529
|
msg = rec.get("message")
|
|
393
530
|
content = msg.get("content") if isinstance(msg, dict) else None
|
|
394
531
|
if isinstance(content, list):
|
|
395
532
|
blocks = [b for b in content if isinstance(b, dict)]
|
|
396
533
|
if not blocks or any(b.get("type") == "tool_result" for b in blocks):
|
|
397
|
-
return
|
|
534
|
+
return None
|
|
398
535
|
text = " ".join(b.get("text") for b in blocks
|
|
399
536
|
if b.get("type") == "text" and isinstance(b.get("text"), str))
|
|
400
537
|
elif isinstance(content, str):
|
|
401
538
|
text = content
|
|
402
539
|
else:
|
|
403
|
-
return
|
|
540
|
+
return None
|
|
404
541
|
head = text.lstrip()
|
|
405
|
-
if head.startswith(NON_HUMAN_PREFIXES):
|
|
406
|
-
return
|
|
407
|
-
|
|
542
|
+
if head.startswith(NON_HUMAN_PREFIXES) or any(p and head.startswith(p) for p in prefixes):
|
|
543
|
+
return None
|
|
544
|
+
if head.startswith(COMMAND_PREFIXES):
|
|
545
|
+
name = COMMAND_NAME_RE.search(head)
|
|
546
|
+
if name and name.group(1).lower() in INERT_COMMANDS:
|
|
547
|
+
return "command"
|
|
548
|
+
return "user"
|
|
408
549
|
|
|
409
550
|
|
|
410
551
|
def _verdict(cls, code, at, reset=None, rtype=""):
|
|
@@ -414,16 +555,23 @@ def _verdict(cls, code, at, reset=None, rtype=""):
|
|
|
414
555
|
|
|
415
556
|
def classify_claude(rec, prefixes=(PROMPT_PREFIX,)):
|
|
416
557
|
"""One transcript record -> None | ("error", verdict) | ("never", code) |
|
|
417
|
-
("cancel", why)
|
|
558
|
+
("cancel", why), why = "user" | "command" (an inert slash command) | "assistant". No time
|
|
559
|
+
filtering here — the tracker applies it."""
|
|
418
560
|
if not isinstance(rec, dict) or rec.get("isSidechain") is True:
|
|
419
561
|
return None
|
|
420
562
|
typ = rec.get("type")
|
|
421
563
|
if typ == "user":
|
|
422
|
-
|
|
564
|
+
human = claude_human_input(rec, prefixes)
|
|
565
|
+
return ("cancel", human) if human else None
|
|
423
566
|
if typ != "assistant":
|
|
424
567
|
return None # system records ("continuing automatically at ...") never count
|
|
425
568
|
if rec.get("isApiErrorMessage") is not True:
|
|
426
569
|
return ("cancel", "assistant") # a real reply: the user or auto-continue moved on
|
|
570
|
+
return claude_api_verdict(rec)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def claude_api_verdict(rec):
|
|
574
|
+
"""An API-error assistant record -> ("error", verdict) | ("never", code)."""
|
|
427
575
|
code = rec.get("error") if isinstance(rec.get("error"), str) else ""
|
|
428
576
|
at = iso_to_epoch(rec.get("timestamp"))
|
|
429
577
|
if rec.get("apiError") == "model_requires_usage_credits":
|
|
@@ -435,6 +583,8 @@ def classify_claude(rec, prefixes=(PROMPT_PREFIX,)):
|
|
|
435
583
|
if rtype in CLAUDE_QUOTA_TYPES:
|
|
436
584
|
return ("error", _verdict("quota", code, at, epoch_of(quota.get("resetsAt")),
|
|
437
585
|
rtype))
|
|
586
|
+
if rtype in CLAUDE_THROTTLE_TYPES:
|
|
587
|
+
return ("error", _verdict("transient", code, at))
|
|
438
588
|
# seven_day_opus, seven_day_overage_included, ...: one model's bucket.
|
|
439
589
|
return ("error", _verdict("model", code, at))
|
|
440
590
|
if MODEL_TEXT.search(_message_text(rec)):
|
|
@@ -452,26 +602,296 @@ def classify_claude(rec, prefixes=(PROMPT_PREFIX,)):
|
|
|
452
602
|
return ("never", code or "?")
|
|
453
603
|
|
|
454
604
|
|
|
605
|
+
def quota_lifted(verdict, now):
|
|
606
|
+
"""A quota verdict whose reset has already passed: the limit it reports is gone, so
|
|
607
|
+
there is nothing to move the session away from (a watcher restarted onto old history,
|
|
608
|
+
a verdict that waited out its reset in a hold). Without a reset (resetsAt absent) the
|
|
609
|
+
verdict stands, as it always has."""
|
|
610
|
+
reset = verdict.get("reset") if verdict.get("class") == "quota" else None
|
|
611
|
+
return reset is not None and now is not None and reset <= now
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def keep_later_reset(into, other):
|
|
615
|
+
"""Several rejections of one class are one verdict, marked with the LATEST reset any of
|
|
616
|
+
them named, and that reset's type: the account is out until every limit it met has
|
|
617
|
+
lifted (the five-hour one in one thread, the weekly one in another)."""
|
|
618
|
+
mine, theirs = into.get("reset"), other.get("reset")
|
|
619
|
+
if theirs is not None and (mine is None or theirs > mine):
|
|
620
|
+
into["reset"], into["rtype"] = theirs, other.get("rtype", "")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def claude_served(rec):
|
|
624
|
+
"""A real reply: an assistant record that is neither an API error nor the client's own
|
|
625
|
+
words (model "<synthetic>") — proof the account served when its request was sent
|
|
626
|
+
(claude_served_at)."""
|
|
627
|
+
if not isinstance(rec, dict) or rec.get("type") != "assistant" \
|
|
628
|
+
or rec.get("isApiErrorMessage") is True:
|
|
629
|
+
return False
|
|
630
|
+
msg = rec.get("message")
|
|
631
|
+
return not (isinstance(msg, dict) and msg.get("model") == "<synthetic>")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# claude 2.1.300+ writes one of these right before each request it sends, main thread and
|
|
635
|
+
# agents alike ({"type":"api-request", ..., "id": ..., "timestamp": ...}), and each reply to
|
|
636
|
+
# that request names it by its "requestRef".
|
|
637
|
+
API_REQUEST = b'{"type":"api-request"'
|
|
638
|
+
REQUESTS_KEPT = 64 # per transcript: a reply's own request is one of its file's newest
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def note_request(rec, requests):
|
|
642
|
+
"""Keep when an api-request record's request was sent: ``requests`` maps id -> epoch,
|
|
643
|
+
the REQUESTS_KEPT newest."""
|
|
644
|
+
rid, at = rec.get("id"), iso_to_epoch(rec.get("timestamp"))
|
|
645
|
+
if isinstance(rid, str) and at is not None:
|
|
646
|
+
requests[rid] = at
|
|
647
|
+
while len(requests) > REQUESTS_KEPT:
|
|
648
|
+
del requests[next(iter(requests))]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def claude_served_at(rec, requests=None):
|
|
652
|
+
"""When the account served a real reply: when its request was sent (its api-request
|
|
653
|
+
record in ``requests``, by the reply's requestRef), else the reply's own timestamp. A
|
|
654
|
+
reply is written as its stream ends, and one the account accepted before a limit can end
|
|
655
|
+
many minutes after it (up to 18 min in the transcripts of October 2026): that proves
|
|
656
|
+
nothing about the limit."""
|
|
657
|
+
at = iso_to_epoch(rec.get("timestamp"))
|
|
658
|
+
ref = rec.get("requestRef")
|
|
659
|
+
sent = requests.get(ref) if requests and isinstance(ref, str) else None
|
|
660
|
+
if sent is None:
|
|
661
|
+
return at
|
|
662
|
+
return sent if at is None else min(at, sent)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def claude_answers(ev, rec):
|
|
666
|
+
"""Whether a main-thread record (``ev``: classify_claude's) answers a rejection of the
|
|
667
|
+
ACCOUNT recorded before it: a real reply (the account served) or the user typing. An
|
|
668
|
+
inert slash command never does (the account is just as limited after /model), nor do the
|
|
669
|
+
client's own <synthetic> words."""
|
|
670
|
+
return ev == ("cancel", "user") or (ev == ("cancel", "assistant") and claude_served(rec))
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def classify_claude_sidechain(rec):
|
|
674
|
+
"""One subagent or workflow transcript record -> None | ("error", verdict) |
|
|
675
|
+
("cancel", "sidechain"). Untimed. A rejection of the ACCOUNT (ACCOUNT_CLASSES) is the
|
|
676
|
+
session's: the main thread would meet it on its next request, and the client, knowing
|
|
677
|
+
that, sends none — on 2026-10-19 two sessions sat idle behind paused workflows whose
|
|
678
|
+
agents alone had recorded the five-hour limit. A real reply (claude_served) proves the
|
|
679
|
+
account serves. A subagent's model-scoped or transient error is its own, and its user
|
|
680
|
+
records are its parent's prompts and tool results, never a human."""
|
|
681
|
+
if not isinstance(rec, dict) or rec.get("type") != "assistant":
|
|
682
|
+
return None
|
|
683
|
+
if rec.get("isApiErrorMessage") is not True:
|
|
684
|
+
return ("cancel", "sidechain") if claude_served(rec) else None
|
|
685
|
+
ev = claude_api_verdict(rec)
|
|
686
|
+
return ev if ev[0] == "error" and ev[1]["class"] in ACCOUNT_CLASSES else None
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# The agent id a detect line may name (the record's agentId, or the file's agent-<id>.jsonl).
|
|
690
|
+
AGENT_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}\Z")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def sidechain_agent(rec, path):
|
|
694
|
+
"""The agentId of a subagent transcript record (its file name's otherwise), for logs."""
|
|
695
|
+
agent = rec.get("agentId")
|
|
696
|
+
if isinstance(agent, str) and AGENT_RE.match(agent):
|
|
697
|
+
return agent
|
|
698
|
+
name = os.path.basename(path)[len("agent-"):-len(".jsonl")]
|
|
699
|
+
return name if AGENT_RE.match(name) else "-"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def side_answer_at(rec, requests=None):
|
|
703
|
+
"""When a subagent transcript record proved the account served (a real reply:
|
|
704
|
+
claude_served_at), or None."""
|
|
705
|
+
return claude_served_at(rec, requests) if claude_served(rec) else None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# What every record that can answer a rejection holds as its "type": a reply (all a
|
|
709
|
+
# subagent's transcript can answer with) and, in the main transcript, the user typing too.
|
|
710
|
+
REPLY_MARKS = (b'"assistant"',)
|
|
711
|
+
ANSWER_MARKS = (b'"assistant"', b'"user"')
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def newest_answer(lines, answer_at, marks=REPLY_MARKS):
|
|
715
|
+
"""(moment, record) of the latest answer among ``lines`` (raw JSONL, in time order), or
|
|
716
|
+
None; ``answer_at`` maps a parsed record to the moment it answered the account, or None.
|
|
717
|
+
Scanned from the end, and only a line holding one of the byte ``marks`` is parsed: the
|
|
718
|
+
newest answer is found within a line or two as a rule, and never costs more than parsing
|
|
719
|
+
the read (in the main transcripts of 2026-10-19, at most 193 lines and 2.7 MB in a row
|
|
720
|
+
held a mark and answered nothing: tool results, request blobs). A reply answers from when
|
|
721
|
+
its request was sent, before records written after it, so the scan goes on to the first
|
|
722
|
+
record written no later than the best moment found: everything before it is older."""
|
|
723
|
+
best = None
|
|
724
|
+
for raw in reversed(lines):
|
|
725
|
+
if not any(mark in raw for mark in marks):
|
|
726
|
+
continue
|
|
727
|
+
rec = read_json_line(raw)
|
|
728
|
+
written = iso_to_epoch(rec.get("timestamp")) if rec is not None else None
|
|
729
|
+
if written is None:
|
|
730
|
+
continue
|
|
731
|
+
if best is not None and written <= best[0]:
|
|
732
|
+
break
|
|
733
|
+
at = answer_at(rec)
|
|
734
|
+
if at is not None and (best is None or at > best[0]):
|
|
735
|
+
best = (at, rec)
|
|
736
|
+
return best
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# The client's own words when the user stops a reply (Esc), mid-stream or in a tool.
|
|
740
|
+
INTERRUPTED = "[Request interrupted by user"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def main_thread_busy(lines):
|
|
744
|
+
"""What a claude session's MAIN thread is in the middle of, from its transcript's newest
|
|
745
|
+
complete lines (raw JSONL, in order): "request" (its newest request has had nothing back),
|
|
746
|
+
"stream" (that request's reply is still streaming), "tool" (a tool the reply started has
|
|
747
|
+
no result yet), or None: idle (the reply finished, failed or was interrupted) or nothing
|
|
748
|
+
tells (no request on record: an older client).
|
|
749
|
+
|
|
750
|
+
Keyed by request, never by the order of the file: the client writes an api-request record
|
|
751
|
+
as it sends (sometimes before the prompt that led to it, and before the previous reply's
|
|
752
|
+
last blocks and tool results), and a reply a record per content block, each naming its
|
|
753
|
+
request (requestRef). A block written mid-stream carries "stop_reason": null; the last one
|
|
754
|
+
says why the reply stopped ("tool_use" can come blocks before its tool_use block), and the
|
|
755
|
+
reply's tools run after it. A block without the key (no client of this era writes one)
|
|
756
|
+
counts as finished: unknown is no reason to wait."""
|
|
757
|
+
lines = list(lines)
|
|
758
|
+
start = rid = None
|
|
759
|
+
for at in range(len(lines) - 1, -1, -1):
|
|
760
|
+
if not lines[at].startswith(API_REQUEST):
|
|
761
|
+
continue
|
|
762
|
+
rec = read_json_line(lines[at])
|
|
763
|
+
if rec is None or rec.get("isSidechain") is True or rec.get("agentId") \
|
|
764
|
+
or rec.get("querySource", "repl_main_thread") != "repl_main_thread":
|
|
765
|
+
continue
|
|
766
|
+
start, rid = at, rec.get("id")
|
|
767
|
+
break
|
|
768
|
+
if start is None or not isinstance(rid, str):
|
|
769
|
+
return None
|
|
770
|
+
blocks, results, stop = [], set(), ""
|
|
771
|
+
for raw in lines[start + 1:]:
|
|
772
|
+
if b'"assistant"' not in raw and b'"user"' not in raw:
|
|
773
|
+
continue
|
|
774
|
+
rec = read_json_line(raw)
|
|
775
|
+
if rec is None or rec.get("isSidechain") is True:
|
|
776
|
+
continue
|
|
777
|
+
msg = rec.get("message") if isinstance(rec.get("message"), dict) else {}
|
|
778
|
+
content = msg.get("content") if isinstance(msg.get("content"), list) else []
|
|
779
|
+
if rec.get("type") == "assistant":
|
|
780
|
+
if rec.get("isApiErrorMessage") is True:
|
|
781
|
+
return None # the request failed
|
|
782
|
+
if rec.get("requestRef") == rid:
|
|
783
|
+
blocks.append(content)
|
|
784
|
+
stop = msg.get("stop_reason", "")
|
|
785
|
+
elif rec.get("type") == "user":
|
|
786
|
+
results.update(b.get("tool_use_id") for b in content
|
|
787
|
+
if isinstance(b, dict) and b.get("type") == "tool_result")
|
|
788
|
+
if _message_text(rec).lstrip().startswith(INTERRUPTED):
|
|
789
|
+
return None # the user stopped it
|
|
790
|
+
if not blocks:
|
|
791
|
+
return "request"
|
|
792
|
+
tools = [b.get("id") for content in blocks for b in content
|
|
793
|
+
if isinstance(b, dict) and b.get("type") == "tool_use"]
|
|
794
|
+
if any(tid not in results for tid in tools):
|
|
795
|
+
return "tool"
|
|
796
|
+
if stop is None or (stop == "tool_use" and not tools):
|
|
797
|
+
return "stream"
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
|
|
455
801
|
class ClaudeTracker:
|
|
456
|
-
"""Folds a claude transcript's records into at most one pending verdict
|
|
802
|
+
"""Folds a claude transcript's records into at most one pending verdict: the main
|
|
803
|
+
thread's own (feed), else an account-level one a subagent transcript recorded
|
|
804
|
+
(feed_side) — the main thread's verdict stands over a subagent's, except that an account
|
|
805
|
+
verdict (ACCOUNT_CLASSES) outranks a model or transient one, whichever thread met it:
|
|
806
|
+
the account serves the main thread no better, and a model/transient verdict waits on
|
|
807
|
+
busy agents (Watcher.decide) that are only being refused by then — on 2026-10-20 a
|
|
808
|
+
session sat six minutes behind its main thread's "concurrents" rejection while its agents
|
|
809
|
+
met the five-hour limit. The main thread's verdict it outranks waits as the fallback, and
|
|
810
|
+
stands once that one is gone (promote)."""
|
|
457
811
|
|
|
458
812
|
def __init__(self, since, prefixes=(PROMPT_PREFIX,)):
|
|
459
813
|
self.since = since
|
|
460
814
|
self.prefixes = prefixes
|
|
461
815
|
self.pending = None
|
|
462
816
|
self.pending_since = None
|
|
817
|
+
# The newest moment since the launch at which something answered a subagent's
|
|
818
|
+
# rejection (the user typing, a real reply anywhere — from when its request was
|
|
819
|
+
# sent). Transcripts are read side by side, not in time order: a rejection older
|
|
820
|
+
# than that is answered already.
|
|
821
|
+
self.cleared = 0.0
|
|
822
|
+
self.requests = {} # the main transcript's requests (note_request)
|
|
823
|
+
# The main thread's model or transient verdict while an account verdict outranks it:
|
|
824
|
+
# only the main thread itself ends that one, never an agent's reply.
|
|
825
|
+
self.fallback = None
|
|
463
826
|
|
|
464
827
|
def clear(self):
|
|
465
828
|
self.pending = None
|
|
466
829
|
self.pending_since = None
|
|
467
830
|
|
|
831
|
+
def reset(self):
|
|
832
|
+
"""Another conversation: nothing pending, and nothing the old one answered."""
|
|
833
|
+
self.clear()
|
|
834
|
+
self.fallback = None
|
|
835
|
+
self.cleared = 0.0
|
|
836
|
+
self.requests = {}
|
|
837
|
+
|
|
838
|
+
def promote(self, now):
|
|
839
|
+
"""The account verdict is gone (an agent's was answered, a quota one's reset passed):
|
|
840
|
+
the main thread's model/transient verdict it outranked stands in its place. Whatever
|
|
841
|
+
the main thread did since has dropped that one already (feed). Returns the verdict
|
|
842
|
+
that stands, or None."""
|
|
843
|
+
fb, self.fallback = self.fallback, None
|
|
844
|
+
if fb is not None:
|
|
845
|
+
self.pending, self.pending_since = fb, now
|
|
846
|
+
return fb
|
|
847
|
+
|
|
848
|
+
def expire(self, now):
|
|
849
|
+
"""A pending quota verdict whose reset passed while it waited (the grace, a hold
|
|
850
|
+
for want of room) is dropped instead of acted on. (dropped, the fallback that
|
|
851
|
+
stands in its place or None)."""
|
|
852
|
+
if self.pending is None or not quota_lifted(self.pending, now):
|
|
853
|
+
return False, None
|
|
854
|
+
self.clear()
|
|
855
|
+
return True, self.promote(now)
|
|
856
|
+
|
|
468
857
|
def wants(self, raw):
|
|
469
858
|
"""Cheap byte prefilter: with nothing pending only an API error can matter (a
|
|
470
859
|
cancel needs something to cancel), so a resumed 100 MB transcript is scanned for
|
|
471
|
-
one substring instead of parsed line by line.
|
|
472
|
-
|
|
860
|
+
one substring instead of parsed line by line. Each request record is read too (it
|
|
861
|
+
dates the replies to that request), and what the prefilter passes over still dates
|
|
862
|
+
the main thread's last answer (passed_over)."""
|
|
863
|
+
return self.pending is not None or b'"isApiErrorMessage"' in raw \
|
|
864
|
+
or raw.startswith(API_REQUEST)
|
|
865
|
+
|
|
866
|
+
def answered(self, stamp):
|
|
867
|
+
"""Something answered the account at ``stamp`` (answer_at, side_answer_at)."""
|
|
868
|
+
if stamp is not None and stamp >= self.since:
|
|
869
|
+
self.cleared = max(self.cleared, stamp)
|
|
870
|
+
|
|
871
|
+
def answer_at(self, ev, rec):
|
|
872
|
+
"""When a main-thread record (``ev``: classify_claude's) answered the account
|
|
873
|
+
(claude_answers): the user typing, at its timestamp; a real reply, when its request
|
|
874
|
+
was sent (claude_served_at). None for anything else, and for an undated answer."""
|
|
875
|
+
if not claude_answers(ev, rec):
|
|
876
|
+
return None
|
|
877
|
+
if ev[1] == "assistant":
|
|
878
|
+
return claude_served_at(rec, self.requests)
|
|
879
|
+
return iso_to_epoch(rec.get("timestamp"))
|
|
880
|
+
|
|
881
|
+
def passed_over(self, lines):
|
|
882
|
+
"""The main-thread lines ``wants`` skipped: nothing was pending, so they cancel
|
|
883
|
+
nothing, but their latest answer still counts against a subagent's rejection older
|
|
884
|
+
than it, read now or later. Without it a watcher restarted onto hours of history
|
|
885
|
+
would act on a workflow agent's old limit in a session served ever since."""
|
|
886
|
+
found = newest_answer(lines, lambda r: self.answer_at(classify_claude(r, self.prefixes), r),
|
|
887
|
+
ANSWER_MARKS)
|
|
888
|
+
if found is not None:
|
|
889
|
+
self.answered(found[0])
|
|
473
890
|
|
|
474
891
|
def feed(self, rec, now):
|
|
892
|
+
if isinstance(rec, dict) and rec.get("type") == "api-request":
|
|
893
|
+
note_request(rec, self.requests)
|
|
894
|
+
return None
|
|
475
895
|
ev = classify_claude(rec, self.prefixes)
|
|
476
896
|
if ev is None:
|
|
477
897
|
return None
|
|
@@ -479,8 +899,26 @@ class ClaudeTracker:
|
|
|
479
899
|
stamp = iso_to_epoch(rec.get("timestamp"))
|
|
480
900
|
if kind == "cancel":
|
|
481
901
|
# A cancel only ever stops an action, so one without a timestamp still counts.
|
|
482
|
-
if
|
|
902
|
+
if stamp is not None and stamp < self.since:
|
|
903
|
+
return None
|
|
904
|
+
answer = claude_answers(ev, rec)
|
|
905
|
+
at = self.answer_at(ev, rec)
|
|
906
|
+
if answer:
|
|
907
|
+
self.answered(at)
|
|
908
|
+
# Whatever the main thread does next ends its own model/transient verdict, the
|
|
909
|
+
# one waiting behind an account verdict too: /model handled it, or it moved on.
|
|
910
|
+
self.fallback = None
|
|
911
|
+
pending = self.pending
|
|
912
|
+
if pending is None:
|
|
483
913
|
return None
|
|
914
|
+
if pending.get("src") == "sidechain":
|
|
915
|
+
# Only an answer ends a subagent's verdict, and only one given after it (a
|
|
916
|
+
# reply from when its request was sent): the main thread's older last word
|
|
917
|
+
# answers nothing (read late, it still says so).
|
|
918
|
+
if not answer or (at is not None and at <= pending["at"]):
|
|
919
|
+
return None
|
|
920
|
+
elif val == "command" and pending["class"] in ACCOUNT_CLASSES:
|
|
921
|
+
return None # /model handles a model's limit or a failed turn, never these
|
|
484
922
|
self.clear()
|
|
485
923
|
return ev
|
|
486
924
|
# An error must PROVE it is from this launch: a resumed transcript carries every
|
|
@@ -489,11 +927,70 @@ class ClaudeTracker:
|
|
|
489
927
|
return None
|
|
490
928
|
if kind == "never":
|
|
491
929
|
return ev
|
|
492
|
-
if
|
|
930
|
+
if quota_lifted(val, now):
|
|
931
|
+
return None # its limit has reset already: nothing to move away from
|
|
932
|
+
pending = self.pending
|
|
933
|
+
if pending is not None and pending["class"] in ACCOUNT_CLASSES \
|
|
934
|
+
and val["class"] not in ACCOUNT_CLASSES:
|
|
935
|
+
# The account's verdict outranks this one, which waits behind it as the main
|
|
936
|
+
# thread's newest word, should an agent's verdict be answered or a reset pass.
|
|
937
|
+
self.fallback = val
|
|
938
|
+
return None
|
|
939
|
+
self.fallback = None # the main thread met something newer: what it met before is moot
|
|
940
|
+
if pending is not None and pending["class"] == val["class"]:
|
|
941
|
+
keep_later_reset(val, pending)
|
|
942
|
+
else:
|
|
493
943
|
self.pending_since = now
|
|
494
944
|
self.pending = val
|
|
495
945
|
return ev
|
|
496
946
|
|
|
947
|
+
def feed_side(self, rec, now, agent="-", requests=None):
|
|
948
|
+
"""One subagent or workflow transcript record (classify_claude_sidechain;
|
|
949
|
+
``requests``: that transcript's, note_request). Its rejection must come from this
|
|
950
|
+
launch's own agents (SIDE_AFTER), be newer than anything that answered it, and name a
|
|
951
|
+
limit that has not reset yet. It outranks the main thread's model/transient verdict,
|
|
952
|
+
which then waits behind it (promote); a main-thread account verdict stands (taking
|
|
953
|
+
this one's later reset when it is of the same class), as does a newer subagent one.
|
|
954
|
+
Only a reply to a request sent after a subagent's verdict cancels it there."""
|
|
955
|
+
ev = classify_claude_sidechain(rec)
|
|
956
|
+
if ev is None:
|
|
957
|
+
return None
|
|
958
|
+
kind, val = ev
|
|
959
|
+
stamp = iso_to_epoch(rec.get("timestamp"))
|
|
960
|
+
if stamp is None or stamp < self.since:
|
|
961
|
+
return None
|
|
962
|
+
pending = self.pending
|
|
963
|
+
if kind == "cancel":
|
|
964
|
+
at = claude_served_at(rec, requests)
|
|
965
|
+
self.answered(at)
|
|
966
|
+
if pending is None or pending.get("src") != "sidechain" or at <= pending["at"]:
|
|
967
|
+
return None
|
|
968
|
+
self.clear()
|
|
969
|
+
# the main thread's own verdict behind it stands again: an agent's reply answers
|
|
970
|
+
# the account, never the main thread's model limit or its failed turn
|
|
971
|
+
promoted = self.promote(now)
|
|
972
|
+
return ("error", promoted) if promoted is not None else ev
|
|
973
|
+
if stamp < self.since + SIDE_AFTER or stamp < self.cleared or quota_lifted(val, now):
|
|
974
|
+
return None
|
|
975
|
+
val.update(src="sidechain", agent=agent)
|
|
976
|
+
if pending is not None and pending["class"] not in ACCOUNT_CLASSES:
|
|
977
|
+
self.fallback = pending # the main thread's model/transient verdict: outranked
|
|
978
|
+
self.pending, self.pending_since = val, now
|
|
979
|
+
return ev
|
|
980
|
+
if pending is not None and pending.get("src") != "sidechain":
|
|
981
|
+
if pending["class"] == val["class"]:
|
|
982
|
+
keep_later_reset(pending, val)
|
|
983
|
+
return None
|
|
984
|
+
if pending is not None and pending["class"] == val["class"]:
|
|
985
|
+
# another agent met the same limit: it still stands, until the later reset
|
|
986
|
+
keep_later_reset(pending, val)
|
|
987
|
+
pending["at"] = max(pending["at"], stamp)
|
|
988
|
+
return None
|
|
989
|
+
if pending is not None and stamp < pending["at"]:
|
|
990
|
+
return None # an older verdict read late never replaces a newer one
|
|
991
|
+
self.pending, self.pending_since = val, now
|
|
992
|
+
return ev
|
|
993
|
+
|
|
497
994
|
|
|
498
995
|
# ---- codex classification -----------------------------------------------------------
|
|
499
996
|
|
|
@@ -709,6 +1206,13 @@ def claude_argv_parse(argv):
|
|
|
709
1206
|
return kept
|
|
710
1207
|
|
|
711
1208
|
|
|
1209
|
+
def codex_effort_ok(val):
|
|
1210
|
+
"""``model_reasoning_effort=<level>``: the one ``-c`` override a relaunch carries (it pins
|
|
1211
|
+
the session's effort); every other config override stays outside the allowlist."""
|
|
1212
|
+
return isinstance(val, str) and val.startswith(CODEX_EFFORT_KEY) \
|
|
1213
|
+
and EFFORT_VALUE_RE.match(val[len(CODEX_EFFORT_KEY):]) is not None
|
|
1214
|
+
|
|
1215
|
+
|
|
712
1216
|
def codex_argv_parse(argv):
|
|
713
1217
|
"""The argv shapes a codex relaunch can rebuild (mirrors bin/codex's ar_argv_ok) ->
|
|
714
1218
|
(kept options, resume target or None), or None for anything else."""
|
|
@@ -725,8 +1229,9 @@ def codex_argv_parse(argv):
|
|
|
725
1229
|
conservative.add(arg)
|
|
726
1230
|
kept += [arg, expected]
|
|
727
1231
|
i += 1
|
|
728
|
-
elif arg in ("-m", "--model"):
|
|
729
|
-
|
|
1232
|
+
elif arg in ("-m", "--model", "-c"):
|
|
1233
|
+
value_ok = codex_effort_ok if arg == "-c" else _value_ok
|
|
1234
|
+
if i + 1 >= len(argv) or not value_ok(argv[i + 1]):
|
|
730
1235
|
return None
|
|
731
1236
|
kept += [arg, argv[i + 1]]
|
|
732
1237
|
i += 1
|
|
@@ -777,11 +1282,16 @@ def prompt_override_ok(text):
|
|
|
777
1282
|
and " " in text and not any(c in text for c in "\0\n\r"))
|
|
778
1283
|
|
|
779
1284
|
|
|
780
|
-
def resume_prompt(cls, override=None):
|
|
1285
|
+
def resume_prompt(cls, override=None, detail=None):
|
|
1286
|
+
"""The relaunch's prompt for a verdict of class ``cls``; ``detail`` (a memory verdict's
|
|
1287
|
+
size) follows the reason in parentheses. The classes that may continue on the same
|
|
1288
|
+
account never claim another one."""
|
|
781
1289
|
reason = REASONS[cls]
|
|
1290
|
+
if detail:
|
|
1291
|
+
reason += " (%s)" % detail
|
|
782
1292
|
if override and prompt_override_ok(override):
|
|
783
1293
|
return override.replace("{reason}", reason)
|
|
784
|
-
where = "" if cls in ("crash", "transient") else " on another account"
|
|
1294
|
+
where = "" if cls in ("crash", "transient", "memory") else " on another account"
|
|
785
1295
|
return PROMPT_TEMPLATE.format(where=where, reason=reason)
|
|
786
1296
|
|
|
787
1297
|
|
|
@@ -792,28 +1302,114 @@ def prompt_prefixes(override=None):
|
|
|
792
1302
|
return tuple(out)
|
|
793
1303
|
|
|
794
1304
|
|
|
795
|
-
def
|
|
796
|
-
"""The
|
|
1305
|
+
def history_model(settings):
|
|
1306
|
+
"""The model ``settings`` names, when it is a value a relaunch can carry; else None."""
|
|
1307
|
+
model = settings.get("model") if isinstance(settings, dict) else None
|
|
1308
|
+
return model if isinstance(model, str) and MODEL_VALUE_RE.match(model) else None
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def settings_argv(provider, settings):
|
|
1312
|
+
"""The flags that pin ``settings`` ({"model", "effort"}, what the session itself last ran
|
|
1313
|
+
with) for ``provider``: model_match.settings_flags over the values a relaunch can carry
|
|
1314
|
+
(anything else counts as unknown). [] when nothing is known."""
|
|
1315
|
+
effort = settings.get("effort") if isinstance(settings, dict) else None
|
|
1316
|
+
effort = effort if isinstance(effort, str) and EFFORT_VALUE_RE.match(effort) else None
|
|
1317
|
+
return settings_flags(provider, history_model(settings), effort)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
def argv_settings(provider, argv):
|
|
1321
|
+
"""{"model": M, "effort": E} that an argv pins, only the ones it names (the last of each
|
|
1322
|
+
wins, as in the CLIs): what a switch line reports the relaunched client was given."""
|
|
1323
|
+
found, i = {}, 0
|
|
1324
|
+
model_opts = ("--model",) if provider == "claude" else ("-m", "--model")
|
|
1325
|
+
while i < len(argv):
|
|
1326
|
+
arg, val = argv[i], (argv[i + 1] if i + 1 < len(argv) else "")
|
|
1327
|
+
if arg.startswith("--model=") and len(arg) > len("--model="):
|
|
1328
|
+
found["model"] = arg[len("--model="):]
|
|
1329
|
+
elif arg in model_opts and _value_ok(val):
|
|
1330
|
+
found["model"] = val
|
|
1331
|
+
i += 1
|
|
1332
|
+
elif provider == "claude" and arg == "--effort" and _value_ok(val):
|
|
1333
|
+
found["effort"] = val
|
|
1334
|
+
i += 1
|
|
1335
|
+
elif provider == "codex" and arg == "-c" and codex_effort_ok(val):
|
|
1336
|
+
found["effort"] = val[len(CODEX_EFFORT_KEY):]
|
|
1337
|
+
i += 1
|
|
1338
|
+
i += 1
|
|
1339
|
+
return found
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def claude_model_left(launch, served):
|
|
1343
|
+
"""Whether a Claude session launched with ``--model <launch>`` has since run on another
|
|
1344
|
+
model (``served``: its history's last). A full id compares without its context suffix, a
|
|
1345
|
+
family alias by family; any other alias (best, opusplan, default), and a served model this
|
|
1346
|
+
cannot read, counts as kept."""
|
|
1347
|
+
if not (isinstance(launch, str) and isinstance(served, str) and MODEL_VALUE_RE.match(served)):
|
|
1348
|
+
return False
|
|
1349
|
+
launch, served = CONTEXT_SUFFIX_RE.sub("", launch), CONTEXT_SUFFIX_RE.sub("", served)
|
|
1350
|
+
if launch.startswith("claude-"):
|
|
1351
|
+
ids = parse_id("claude", launch), parse_id("claude", served) # a dated id names the same model
|
|
1352
|
+
return ids[0] != ids[1] if ids[0][0] and ids[1][0] else launch != served
|
|
1353
|
+
family = parse_id("claude", served)[0] if launch in CLAUDE_FAMILY_ALIASES else None
|
|
1354
|
+
return family is not None and family != launch
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def without_settings(kept, model, effort):
|
|
1358
|
+
"""``kept`` without its model options (-m V, --model V, --model=V) when ``model`` and its
|
|
1359
|
+
effort options (--effort V, codex's -c model_reasoning_effort=V) when ``effort``. Both
|
|
1360
|
+
parsers refuse a value that starts with "-", so no value is ever taken for an option."""
|
|
1361
|
+
out, i = [], 0
|
|
1362
|
+
while i < len(kept):
|
|
1363
|
+
arg = kept[i]
|
|
1364
|
+
if (model and arg in ("-m", "--model")) or (effort and arg in ("--effort", "-c")):
|
|
1365
|
+
i += 2
|
|
1366
|
+
continue
|
|
1367
|
+
if not (model and arg.startswith("--model=")):
|
|
1368
|
+
out.append(arg)
|
|
1369
|
+
i += 1
|
|
1370
|
+
return out
|
|
797
1371
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1372
|
+
|
|
1373
|
+
def relaunch_argv(provider, argv, sid, cls, prompt=None, settings=None, detail=None):
|
|
1374
|
+
"""The argv the relaunched shim runs, built from the ORIGINAL argv (``detail``: what the
|
|
1375
|
+
prompt adds to the reason, resume_prompt).
|
|
1376
|
+
|
|
1377
|
+
claude: the kept options + ``--resume <sid> <prompt>``.
|
|
801
1378
|
codex: ``resume <kept options> <sid> <prompt>``.
|
|
1379
|
+
``settings`` ({"model", "effort"}, read from the session's own history) pins what the
|
|
1380
|
+
session last ran with, a /model or /effort since the launch included: the flags go right
|
|
1381
|
+
before the session id (claude: before --resume) and replace the launch's own options.
|
|
1382
|
+
Codex drops the thread's other saved value on a model or an effort flag alone, so it gets
|
|
1383
|
+
both or neither (model_match.settings_flags), and when the history names the model but no
|
|
1384
|
+
effort to pin with it, the launch's own model/effort options go too: Codex then restores
|
|
1385
|
+
both from the thread's shared state. Claude gets the effort alone (--resume never restores
|
|
1386
|
+
it): an explicit --model turns off --resume's own model restore and puts bin/claude in
|
|
1387
|
+
explicit-model mode, where its model-scoped fallback no longer applies -- so the launch's
|
|
1388
|
+
--model is replayed, and dropped only once the history shows the session left it. Without
|
|
1389
|
+
settings the launch's options are replayed as given.
|
|
802
1390
|
None when the argv is outside the allowlist or the id is not a session id."""
|
|
803
1391
|
if not uuid_ok(sid) or cls not in REASONS:
|
|
804
1392
|
return None
|
|
805
|
-
text = resume_prompt(cls, prompt)
|
|
1393
|
+
text = resume_prompt(cls, prompt, detail)
|
|
806
1394
|
if provider == "claude":
|
|
807
|
-
kept = claude_argv_parse(argv)
|
|
808
|
-
|
|
809
|
-
return None
|
|
810
|
-
return kept + ["--resume", sid, text]
|
|
811
|
-
if provider == "codex":
|
|
1395
|
+
kept, head, tail = claude_argv_parse(argv), [], ["--resume", sid, text]
|
|
1396
|
+
elif provider == "codex":
|
|
812
1397
|
parsed = codex_argv_parse(argv)
|
|
813
|
-
if parsed is None
|
|
814
|
-
|
|
815
|
-
return
|
|
816
|
-
|
|
1398
|
+
kept, head, tail = (None if parsed is None else parsed[0]), ["resume"], [sid, text]
|
|
1399
|
+
else:
|
|
1400
|
+
return None
|
|
1401
|
+
if kept is None:
|
|
1402
|
+
return None
|
|
1403
|
+
settings = settings if isinstance(settings, dict) else {}
|
|
1404
|
+
left = False
|
|
1405
|
+
if provider == "claude":
|
|
1406
|
+
left = claude_model_left(argv_settings("claude", kept).get("model"), settings.get("model"))
|
|
1407
|
+
settings = {"effort": settings.get("effort")}
|
|
1408
|
+
flags = settings_argv(provider, settings)
|
|
1409
|
+
model, effort = left or "--model" in flags or "-m" in flags, "--effort" in flags or "-c" in flags
|
|
1410
|
+
if provider == "codex" and not flags and history_model(settings):
|
|
1411
|
+
model = effort = True # no launch -m or -c alone: neither, and the thread restores both
|
|
1412
|
+
return head + without_settings(kept, model, effort) + flags + tail
|
|
817
1413
|
|
|
818
1414
|
|
|
819
1415
|
# ---- relaunch / state files ---------------------------------------------------------
|
|
@@ -962,6 +1558,8 @@ def load_state(path):
|
|
|
962
1558
|
"rollout_cursor": kv.get("rollout_cursor", ""),
|
|
963
1559
|
**{key: kv.get(key, "") for key in ("avoid_claude", "avoid_codex", "pool_claude",
|
|
964
1560
|
"pool_codex", "handoff_token")},
|
|
1561
|
+
# claude: the credential the shim launched the run on (bin/claude ar_start_watcher)
|
|
1562
|
+
"via": kv.get("via") if kv.get("via") in ("token", "login") else "",
|
|
965
1563
|
}
|
|
966
1564
|
|
|
967
1565
|
|
|
@@ -1122,6 +1720,104 @@ def notice_argv(sock, pane, provider, sid, sticky=True):
|
|
|
1122
1720
|
"claude-multiacc: auto-resume failed — run: " + resume_hint(provider, sid))
|
|
1123
1721
|
|
|
1124
1722
|
|
|
1723
|
+
def capture_argv(sock, pane):
|
|
1724
|
+
"""The pane's screen as text (-p), its wrapped rows joined (-J). Read-only: the memory
|
|
1725
|
+
watch's one look at the pane, never a key."""
|
|
1726
|
+
return tmux_cmd(sock, "capture-pane", "-p", "-J", "-t", pane)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def screen_rows(text):
|
|
1730
|
+
"""A captured screen's rows as plain text: escape sequences and controls dropped, trailing
|
|
1731
|
+
spaces and blank bottom rows gone. The client's no-break spaces stay: one marks the
|
|
1732
|
+
prompt's row (PROMPT_ROW_RE), the last character of an empty prompt."""
|
|
1733
|
+
rows = [CONTROL_RE.sub("", ANSI_RE.sub("", row)).rstrip(" \t")
|
|
1734
|
+
for row in (text or "").split("\n")]
|
|
1735
|
+
while rows and not rows[-1].strip():
|
|
1736
|
+
rows.pop()
|
|
1737
|
+
return rows
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def prompt_box(rows):
|
|
1741
|
+
"""(top, bottom): the rows of the two rules of the prompt's box (RULE_RE, PROMPT_ROW_RE)
|
|
1742
|
+
among ``rows``, or None. The box is the last prompt row on screen with a rule right above
|
|
1743
|
+
it, closed by the first rule after it within PROMPT_ROWS rows. Rows the box holds are
|
|
1744
|
+
indented past the mode indicator, so none of them reads as either."""
|
|
1745
|
+
for at in range(len(rows) - 1, 0, -1):
|
|
1746
|
+
if PROMPT_ROW_RE.match(rows[at]) and RULE_RE.match(rows[at - 1]):
|
|
1747
|
+
for end in range(at + 1, min(len(rows), at + 1 + PROMPT_ROWS)):
|
|
1748
|
+
if RULE_RE.match(rows[end]):
|
|
1749
|
+
return at - 1, end
|
|
1750
|
+
return None
|
|
1751
|
+
return None
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def banner_size(row):
|
|
1755
|
+
"""The size in the client's critical-memory banner on one row, or None: MEMORY_BANNER_RE,
|
|
1756
|
+
then MEMORY_TAIL -- whole, with or without its optional end, or a start of it and the
|
|
1757
|
+
ellipsis it was cut at -- and after that nothing to the row's end but the client's own
|
|
1758
|
+
pills (MEMORY_AFTER_RE). Words after the banner or after its ellipsis are not the
|
|
1759
|
+
client's, and neither is a hyphen or an en dash after the size."""
|
|
1760
|
+
for found in MEMORY_BANNER_RE.finditer(row):
|
|
1761
|
+
rest = row[found.end():]
|
|
1762
|
+
cut = rest.find("…")
|
|
1763
|
+
if 0 <= cut < len(MEMORY_TAIL) and MEMORY_TAIL.startswith(rest[:cut]):
|
|
1764
|
+
after = rest[cut + 1:]
|
|
1765
|
+
elif rest.startswith(MEMORY_TAIL):
|
|
1766
|
+
after = rest[len(MEMORY_TAIL):]
|
|
1767
|
+
elif rest.startswith(MEMORY_TAIL[:MEMORY_TAIL_MIN]):
|
|
1768
|
+
after = rest[MEMORY_TAIL_MIN:]
|
|
1769
|
+
else:
|
|
1770
|
+
continue
|
|
1771
|
+
if MEMORY_AFTER_RE.fullmatch(after):
|
|
1772
|
+
return found.group(1)
|
|
1773
|
+
return None
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def memory_banner(text):
|
|
1777
|
+
"""The size the client's critical-memory banner names on a captured screen, or None. Only
|
|
1778
|
+
the client's own status counts: the footer of the prompt's box (prompt_box), the rows
|
|
1779
|
+
under its bottom rule down to the first blank row. The conversation is above the box and
|
|
1780
|
+
the draft inside it, so the banner's words in a reply, a quoted log line, a tool's output
|
|
1781
|
+
or the prompt never count, and neither do the background tasks below the footer -- nor a
|
|
1782
|
+
task's row under its first row (TASK_ROW_RE), blank row or none. A screen with no prompt
|
|
1783
|
+
box (a dialog in its place, the transcript view, a picker) has no banner."""
|
|
1784
|
+
rows = screen_rows(text)
|
|
1785
|
+
box = prompt_box(rows)
|
|
1786
|
+
if box is None:
|
|
1787
|
+
return None
|
|
1788
|
+
for at, row in enumerate(rows[box[1] + 1:]):
|
|
1789
|
+
if not row.strip():
|
|
1790
|
+
break
|
|
1791
|
+
if at and TASK_ROW_RE.match(row):
|
|
1792
|
+
continue
|
|
1793
|
+
size = banner_size(row)
|
|
1794
|
+
if size:
|
|
1795
|
+
return size
|
|
1796
|
+
return None
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def compacting(text):
|
|
1800
|
+
"""Whether a captured screen shows the client compacting its conversation: COMPACTING in
|
|
1801
|
+
one of the MEMORY_ROWS rows above the prompt's box (its spinner, the todo list between it
|
|
1802
|
+
and the box). Read generously -- the conversation's own words can match -- as all a reading
|
|
1803
|
+
does is make a memory verdict wait for the compaction to end, MEMORY_BUSY_CAP at most."""
|
|
1804
|
+
rows = screen_rows(text)
|
|
1805
|
+
box = prompt_box(rows)
|
|
1806
|
+
return box is not None and any(COMPACTING in row
|
|
1807
|
+
for row in rows[max(0, box[0] - MEMORY_ROWS):box[0]])
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def format_size(nbytes):
|
|
1811
|
+
"""Bytes the way the client prints its heap (8.3GB, 512MB): one decimal, ".0" dropped."""
|
|
1812
|
+
val, unit = nbytes / 1024.0, "KB"
|
|
1813
|
+
for bigger in ("MB", "GB"):
|
|
1814
|
+
if val < 1024:
|
|
1815
|
+
break
|
|
1816
|
+
val, unit = val / 1024.0, bigger
|
|
1817
|
+
text = "%.1f" % val
|
|
1818
|
+
return (text[:-2] if text.endswith(".0") else text) + unit
|
|
1819
|
+
|
|
1820
|
+
|
|
1125
1821
|
# ---- side effects -------------------------------------------------------------------
|
|
1126
1822
|
|
|
1127
1823
|
def descendants(table, pid):
|
|
@@ -1268,6 +1964,36 @@ class Runner:
|
|
|
1268
1964
|
return None
|
|
1269
1965
|
return [line[1:] for line in out.split("\n") if line.startswith("n")]
|
|
1270
1966
|
|
|
1967
|
+
def footprint(self, pid):
|
|
1968
|
+
"""The memory ``pid`` holds, in bytes, or None when it cannot be read. Linux: VmRSS +
|
|
1969
|
+
VmSwap. macOS: its phys_footprint (what Activity Monitor calls Memory), from libproc's
|
|
1970
|
+
proc_pid_rusage -- rusage_info_v0 is the uuid then ten uint64s, the footprint the
|
|
1971
|
+
eighth -- for a process of this user. Read-only, no child process."""
|
|
1972
|
+
if os.path.isdir("/proc/self"):
|
|
1973
|
+
found = {}
|
|
1974
|
+
try:
|
|
1975
|
+
with open("/proc/%d/status" % pid) as fh:
|
|
1976
|
+
for line in fh:
|
|
1977
|
+
key, _, rest = line.partition(":")
|
|
1978
|
+
if key in ("VmRSS", "VmSwap"):
|
|
1979
|
+
found[key] = int(rest.split()[0]) * 1024
|
|
1980
|
+
except (OSError, ValueError, IndexError):
|
|
1981
|
+
return None
|
|
1982
|
+
return sum(found.values()) if "VmRSS" in found else None
|
|
1983
|
+
if sys.platform != "darwin":
|
|
1984
|
+
return None
|
|
1985
|
+
try:
|
|
1986
|
+
import ctypes
|
|
1987
|
+
call = ctypes.CDLL("/usr/lib/libproc.dylib").proc_pid_rusage
|
|
1988
|
+
call.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p]
|
|
1989
|
+
call.restype = ctypes.c_int
|
|
1990
|
+
buf = ctypes.create_string_buffer(256)
|
|
1991
|
+
if call(pid, 0, buf) != 0: # RUSAGE_INFO_V0
|
|
1992
|
+
return None
|
|
1993
|
+
return struct.unpack_from("=Q", buf.raw, 16 + 7 * 8)[0]
|
|
1994
|
+
except (ImportError, OSError, AttributeError, ValueError, struct.error):
|
|
1995
|
+
return None
|
|
1996
|
+
|
|
1271
1997
|
|
|
1272
1998
|
# ---- transcript / rollout tailing ---------------------------------------------------
|
|
1273
1999
|
|
|
@@ -1285,8 +2011,13 @@ class JsonlTail:
|
|
|
1285
2011
|
self.buf = b""
|
|
1286
2012
|
self.skip = False
|
|
1287
2013
|
self.ino = None
|
|
2014
|
+
self.got = 0 # bytes the last read took
|
|
2015
|
+
self.behind = 0 # bytes the file held beyond them
|
|
2016
|
+
self.requests = {} # claude subagent transcripts: their requests (note_request)
|
|
1288
2017
|
|
|
1289
|
-
def read_lines(self):
|
|
2018
|
+
def read_lines(self, limit=None):
|
|
2019
|
+
"""``limit`` caps the bytes this read takes (MAX_READ at most)."""
|
|
2020
|
+
self.got = self.behind = 0
|
|
1290
2021
|
try:
|
|
1291
2022
|
fh = open(self.path, "rb")
|
|
1292
2023
|
except OSError:
|
|
@@ -1302,8 +2033,9 @@ class JsonlTail:
|
|
|
1302
2033
|
if st.st_size == self.off:
|
|
1303
2034
|
return []
|
|
1304
2035
|
fh.seek(self.off)
|
|
1305
|
-
data = fh.read(self.MAX_READ)
|
|
2036
|
+
data = fh.read(self.MAX_READ if limit is None else max(1, min(limit, self.MAX_READ)))
|
|
1306
2037
|
self.off += len(data)
|
|
2038
|
+
self.got, self.behind = len(data), max(0, st.st_size - self.off)
|
|
1307
2039
|
if self.skip:
|
|
1308
2040
|
cut = data.find(b"\n")
|
|
1309
2041
|
if cut < 0:
|
|
@@ -1391,6 +2123,19 @@ class Quoted(str):
|
|
|
1391
2123
|
"""A log value written as ``key="value"`` with its spaces kept (the resume command)."""
|
|
1392
2124
|
|
|
1393
2125
|
|
|
2126
|
+
class Pacing:
|
|
2127
|
+
"""What decide() keeps while it acts on one verdict: when it last probed for room (again
|
|
2128
|
+
HOLD_REPROBE s later at the soonest), and whether its hold and its wait for the main
|
|
2129
|
+
thread are logged (once each). The transcript's verdict and the memory verdict have one
|
|
2130
|
+
each, so neither one's coming and going re-probes or re-logs the other."""
|
|
2131
|
+
|
|
2132
|
+
__slots__ = ("probed", "held", "waited")
|
|
2133
|
+
|
|
2134
|
+
def __init__(self):
|
|
2135
|
+
self.probed = None
|
|
2136
|
+
self.held = self.waited = False
|
|
2137
|
+
|
|
2138
|
+
|
|
1394
2139
|
class Watcher:
|
|
1395
2140
|
"""One supervised session: tail, classify, decide, stop, relaunch — or just exit."""
|
|
1396
2141
|
|
|
@@ -1417,21 +2162,41 @@ class Watcher:
|
|
|
1417
2162
|
self.cwd = None
|
|
1418
2163
|
self.tail = None
|
|
1419
2164
|
self.transcript = None
|
|
2165
|
+
self.side = {} # claude: subagent/workflow transcript path -> JsonlTail
|
|
2166
|
+
self.side_order = [] # the ones followed, the most recently written first
|
|
2167
|
+
self.side_behind = False # the last read of them left lines unread
|
|
2168
|
+
self.next_side = 0.0
|
|
2169
|
+
self.recent = deque() # claude: the main transcript's newest lines (MAIN_RECENT_BYTES)
|
|
2170
|
+
self.recent_bytes = 0
|
|
2171
|
+
self.busy_memo = None # (lines read so far, main_thread_busy of them)
|
|
2172
|
+
self.lines_read = 0
|
|
1420
2173
|
self.lstart = None
|
|
1421
2174
|
self.started = None
|
|
1422
2175
|
self.next_find = 0.0
|
|
1423
2176
|
self.next_lsof = 0.0
|
|
1424
2177
|
self.next_fallback = 0.0
|
|
1425
2178
|
self.never_seen = set()
|
|
1426
|
-
self.
|
|
1427
|
-
self.last_probe = None
|
|
2179
|
+
self.pace = Pacing() # the transcript's verdict's probe and logs (rearm)
|
|
1428
2180
|
self.cross_plan = None
|
|
2181
|
+
self.served_model = None # the model this session's history last names (relaunch_settings)
|
|
1429
2182
|
self.claim = os.path.join(self.stdir, "%d.rollout" % self.pid)
|
|
1430
2183
|
self.root = None # the pool, when the relaunch has to name it
|
|
1431
2184
|
self.stopped = False # True once this watcher has signalled the client
|
|
1432
2185
|
self.tree = [] # [(pid, lstart)] of the client's descendants at the stop
|
|
1433
2186
|
self._open_files_ok = None
|
|
1434
2187
|
self.lookup_failed_at = None
|
|
2188
|
+
# claude: the client's critical-memory banner (watch_memory). A verdict of its own,
|
|
2189
|
+
# beside the tracker's: it waits behind any transcript verdict, and only the banner
|
|
2190
|
+
# going away ends it.
|
|
2191
|
+
self.memory = None
|
|
2192
|
+
self.memory_since = None
|
|
2193
|
+
self.memory_pace = Pacing() # its own: the transcript's verdict never shares it
|
|
2194
|
+
self.next_memory = 0.0
|
|
2195
|
+
self.memory_off = False # this process's memory relaunches are over (memory-budget)
|
|
2196
|
+
# What the memory verdict waits for, each since it was first seen (memory_waits): the
|
|
2197
|
+
# main thread's work in flight, and a compaction the pane shows under way.
|
|
2198
|
+
self.memory_busy_since = None
|
|
2199
|
+
self.memory_compact_since = None
|
|
1435
2200
|
|
|
1436
2201
|
# -- logging --
|
|
1437
2202
|
|
|
@@ -1497,7 +2262,8 @@ class Watcher:
|
|
|
1497
2262
|
if not self.r.alive(self.pid):
|
|
1498
2263
|
return self.on_exit()
|
|
1499
2264
|
self.read()
|
|
1500
|
-
|
|
2265
|
+
self.watch_memory()
|
|
2266
|
+
if self.tracker.pending is not None or self.memory is not None:
|
|
1501
2267
|
rc = self.decide()
|
|
1502
2268
|
if rc is not None:
|
|
1503
2269
|
return rc
|
|
@@ -1568,8 +2334,11 @@ class Watcher:
|
|
|
1568
2334
|
# included, which is not this client's and not this account's.
|
|
1569
2335
|
# Only what is written from now on is ours.
|
|
1570
2336
|
self.tracker.since = max(self.tracker.since, now - 2)
|
|
1571
|
-
self.tracker.
|
|
2337
|
+
self.tracker.reset()
|
|
1572
2338
|
self.sid, self.tail, self.transcript = sid, None, None
|
|
2339
|
+
self.side, self.side_order, self.side_behind, self.next_side = {}, [], False, 0.0
|
|
2340
|
+
self.recent.clear()
|
|
2341
|
+
self.recent_bytes, self.busy_memo = 0, None
|
|
1573
2342
|
if cwd:
|
|
1574
2343
|
self.cwd = cwd
|
|
1575
2344
|
if self.sid and self.tail is None and now >= self.next_find:
|
|
@@ -1581,7 +2350,91 @@ class Watcher:
|
|
|
1581
2350
|
self.tail = JsonlTail(found[0])
|
|
1582
2351
|
self.dbg("transcript %s" % found[0])
|
|
1583
2352
|
if self.tail is not None:
|
|
1584
|
-
|
|
2353
|
+
# The main thread's own record first, and the newest answer among what the
|
|
2354
|
+
# prefilter skipped with it; the subagents' is read once the main transcript is
|
|
2355
|
+
# caught up (a resumed one can take several reads).
|
|
2356
|
+
self.tracker.passed_over(self.pump())
|
|
2357
|
+
if self.tail.behind == 0:
|
|
2358
|
+
self.read_sidechains()
|
|
2359
|
+
|
|
2360
|
+
def read_sidechains(self):
|
|
2361
|
+
"""This session's subagent and workflow transcripts, for what they can say about
|
|
2362
|
+
the ACCOUNT (classify_claude_sidechain). Only API-error and request lines are parsed,
|
|
2363
|
+
plus each read's latest reply (newest_answer), which dates the account's last
|
|
2364
|
+
service in it from when that reply's request was sent; the replies go first, so a
|
|
2365
|
+
rejection they answer never shows as pending."""
|
|
2366
|
+
now = self.r.now()
|
|
2367
|
+
if now >= self.next_side:
|
|
2368
|
+
self.next_side = now + self.cfg.subagent_scan
|
|
2369
|
+
self.find_sidechains()
|
|
2370
|
+
budget, replies, errors, behind = SIDE_BYTES, [], [], False
|
|
2371
|
+
for path in self.side_order:
|
|
2372
|
+
if budget <= 0:
|
|
2373
|
+
behind = True
|
|
2374
|
+
break
|
|
2375
|
+
tail = self.side[path]
|
|
2376
|
+
lines = tail.read_lines(budget)
|
|
2377
|
+
budget -= tail.got
|
|
2378
|
+
behind = behind or tail.behind > 0
|
|
2379
|
+
for raw in lines:
|
|
2380
|
+
if raw.startswith(API_REQUEST):
|
|
2381
|
+
rec = read_json_line(raw)
|
|
2382
|
+
if rec is not None:
|
|
2383
|
+
note_request(rec, tail.requests)
|
|
2384
|
+
found = newest_answer(lines, lambda rec, sent=tail.requests: side_answer_at(
|
|
2385
|
+
rec, sent)) if lines else None
|
|
2386
|
+
if found is not None:
|
|
2387
|
+
replies.append((found[1], path))
|
|
2388
|
+
errors += [(raw, path) for raw in lines if b'"isApiErrorMessage"' in raw]
|
|
2389
|
+
self.side_behind = behind
|
|
2390
|
+
for rec, path in replies + [(read_json_line(raw), path) for raw, path in errors]:
|
|
2391
|
+
if rec is None:
|
|
2392
|
+
continue
|
|
2393
|
+
before = self.tracker.pending
|
|
2394
|
+
ev = self.tracker.feed_side(rec, now, sidechain_agent(rec, path),
|
|
2395
|
+
self.side[path].requests)
|
|
2396
|
+
if ev is not None:
|
|
2397
|
+
self.on_event(ev, before)
|
|
2398
|
+
|
|
2399
|
+
def find_sidechains(self):
|
|
2400
|
+
"""agent-*.jsonl anywhere under projects/*/<sid>/subagents (subagents/ itself,
|
|
2401
|
+
workflows/wf_*/), in a walk of at most SIDE_SCAN entries; never journal.jsonl or
|
|
2402
|
+
*.meta.json. The SIDE_FILES written most recently are followed. One new to the
|
|
2403
|
+
watcher starts at its end when it was last written before this launch, else at its
|
|
2404
|
+
start (the tracker's time filter still applies). A file no longer followed keeps
|
|
2405
|
+
its place for as long as it is there."""
|
|
2406
|
+
found, budget = [], SIDE_SCAN
|
|
2407
|
+
for base in sorted(glob.glob(os.path.join(self.acct_dir, "projects", "*", self.sid,
|
|
2408
|
+
"subagents"))):
|
|
2409
|
+
for root, _dirs, files in os.walk(base):
|
|
2410
|
+
for name in files[:budget]:
|
|
2411
|
+
if not (name.startswith("agent-") and name.endswith(".jsonl")):
|
|
2412
|
+
continue
|
|
2413
|
+
path = os.path.join(root, name)
|
|
2414
|
+
try:
|
|
2415
|
+
st = os.stat(path)
|
|
2416
|
+
except OSError:
|
|
2417
|
+
continue
|
|
2418
|
+
if stat.S_ISREG(st.st_mode):
|
|
2419
|
+
found.append((st.st_mtime, path, st))
|
|
2420
|
+
budget -= min(len(files), budget)
|
|
2421
|
+
if budget <= 0:
|
|
2422
|
+
break
|
|
2423
|
+
if budget <= 0:
|
|
2424
|
+
break
|
|
2425
|
+
found.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
|
|
2426
|
+
side = {}
|
|
2427
|
+
for rank, (mtime, path, st) in enumerate(found):
|
|
2428
|
+
tail = self.side.get(path)
|
|
2429
|
+
if tail is None and rank < SIDE_FILES:
|
|
2430
|
+
tail = JsonlTail(path)
|
|
2431
|
+
if mtime < self.tracker.since - SIDE_SLACK:
|
|
2432
|
+
tail.off, tail.ino = st.st_size, st.st_ino
|
|
2433
|
+
self.dbg("sidechain %s from %s" % (path, "its end" if tail.off else "the start"))
|
|
2434
|
+
if tail is not None:
|
|
2435
|
+
side[path] = tail
|
|
2436
|
+
self.side = side
|
|
2437
|
+
self.side_order = [path for _mtime, path, _st in found[:SIDE_FILES]]
|
|
1585
2438
|
|
|
1586
2439
|
def read_codex(self):
|
|
1587
2440
|
if self.tail is None and not self.discover():
|
|
@@ -1589,9 +2442,15 @@ class Watcher:
|
|
|
1589
2442
|
self.pump()
|
|
1590
2443
|
|
|
1591
2444
|
def pump(self):
|
|
2445
|
+
"""Feed what the transcript or rollout wrote since the last read; returns the lines
|
|
2446
|
+
the tracker's prefilter passed over."""
|
|
1592
2447
|
now = self.r.now()
|
|
2448
|
+
passed = []
|
|
1593
2449
|
for raw in self.tail.read_lines():
|
|
2450
|
+
if self.provider == "claude":
|
|
2451
|
+
self.remember(raw)
|
|
1594
2452
|
if not self.tracker.wants(raw):
|
|
2453
|
+
passed.append(raw)
|
|
1595
2454
|
continue
|
|
1596
2455
|
rec = read_json_line(raw)
|
|
1597
2456
|
if rec is None:
|
|
@@ -1600,6 +2459,23 @@ class Watcher:
|
|
|
1600
2459
|
ev = self.tracker.feed(rec, now)
|
|
1601
2460
|
if ev is not None:
|
|
1602
2461
|
self.on_event(ev, before)
|
|
2462
|
+
return passed
|
|
2463
|
+
|
|
2464
|
+
def remember(self, raw):
|
|
2465
|
+
"""Keep a main-transcript line among the newest MAIN_RECENT_BYTES (the newest one
|
|
2466
|
+
always), for main_busy: no second read of the file."""
|
|
2467
|
+
self.recent.append(raw)
|
|
2468
|
+
self.recent_bytes += len(raw)
|
|
2469
|
+
self.lines_read += 1
|
|
2470
|
+
while self.recent_bytes > MAIN_RECENT_BYTES and len(self.recent) > 1:
|
|
2471
|
+
self.recent_bytes -= len(self.recent.popleft())
|
|
2472
|
+
|
|
2473
|
+
def rearm(self):
|
|
2474
|
+
"""Any change of the transcript's state re-arms the hold logs and probes immediately:
|
|
2475
|
+
its verdict's, and the memory verdict's, which waits behind any of them. The memory
|
|
2476
|
+
verdict's own coming and going re-arms only its own (memory_update)."""
|
|
2477
|
+
self.pace = Pacing()
|
|
2478
|
+
self.memory_pace = Pacing()
|
|
1603
2479
|
|
|
1604
2480
|
def on_event(self, ev, before):
|
|
1605
2481
|
kind, val = ev
|
|
@@ -1608,16 +2484,16 @@ class Watcher:
|
|
|
1608
2484
|
self.never_seen.add(val)
|
|
1609
2485
|
self.log("never", code=val, sid=self.sid, acct=self.acct)
|
|
1610
2486
|
return
|
|
1611
|
-
|
|
1612
|
-
self.hold_logged = False
|
|
1613
|
-
self.last_probe = None
|
|
2487
|
+
self.rearm()
|
|
1614
2488
|
if kind == "cancel":
|
|
1615
2489
|
self.dbg("verdict cancelled by %s" % val)
|
|
1616
2490
|
return
|
|
1617
2491
|
if before is None or before["class"] != val["class"]:
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2492
|
+
fields = {"class": val["class"], "code": val["code"], "sid": self.sid,
|
|
2493
|
+
"acct": self.acct, "reset": val.get("reset") or "-"}
|
|
2494
|
+
if val.get("src") == "sidechain":
|
|
2495
|
+
fields.update(src="sidechain", agent=val.get("agent") or "-")
|
|
2496
|
+
self.log("detect", **fields)
|
|
1621
2497
|
|
|
1622
2498
|
# -- codex rollout discovery --
|
|
1623
2499
|
|
|
@@ -1714,6 +2590,83 @@ class Watcher:
|
|
|
1714
2590
|
return self.attach(cands[0], meta, "cwd-fallback")
|
|
1715
2591
|
return False
|
|
1716
2592
|
|
|
2593
|
+
# -- the client's critical-memory banner (claude) --
|
|
2594
|
+
|
|
2595
|
+
def client_in_front(self):
|
|
2596
|
+
"""The client runs in the foreground of its terminal and is not stopped (Ctrl-Z): the
|
|
2597
|
+
pane shows its screen, and once it ends the prompt is the shell's. The suites'
|
|
2598
|
+
no-terminal knob waives the foreground (a client without a terminal has none)."""
|
|
2599
|
+
client = self.r.proc_status(self.pid)
|
|
2600
|
+
flags = client[0] if client else ""
|
|
2601
|
+
return bool(client) and "T" not in flags and not flags.startswith("t") \
|
|
2602
|
+
and ("+" in flags or self.cfg.test_tty)
|
|
2603
|
+
|
|
2604
|
+
def watch_memory(self):
|
|
2605
|
+
"""Every MEMORY_SCAN s, once the launch is MEMORY_MIN_UPTIME old (a resumed session
|
|
2606
|
+
can come back as heavy as it left, and must not be restarted in a loop), read whether
|
|
2607
|
+
the client is at critical memory (memory_reading). A reading makes the memory verdict,
|
|
2608
|
+
which decide() acts on like any other; one without ends it: /compact can bring the
|
|
2609
|
+
heap back under the line, and the banner goes with it."""
|
|
2610
|
+
if self.provider != "claude" or self.memory_off or self.cfg.memory_scan <= 0:
|
|
2611
|
+
return
|
|
2612
|
+
now = self.r.now()
|
|
2613
|
+
if now < self.next_memory or now - self.launched < self.cfg.memory_min_uptime:
|
|
2614
|
+
return
|
|
2615
|
+
self.next_memory = now + self.cfg.memory_scan
|
|
2616
|
+
self.memory_update(self.memory_reading(), now)
|
|
2617
|
+
|
|
2618
|
+
def memory_reading(self):
|
|
2619
|
+
"""(code, size, src) while the client is at critical memory, else None: its banner on
|
|
2620
|
+
this pane (critical_memory, src=pane), else -- only with MEMORY_LIMIT_MB set -- its own
|
|
2621
|
+
memory at that limit or over (memory_limit, src=process). Read only for a session a
|
|
2622
|
+
relaunch can name, while the pane is still the launching shell's and the client runs
|
|
2623
|
+
in front of it: capture-pane on the watcher's own socket and pane, never a key."""
|
|
2624
|
+
if not self.sid or not self.transcript:
|
|
2625
|
+
return None
|
|
2626
|
+
sock = tmux_socket(self.st["tmux"])
|
|
2627
|
+
if not sock or not self.pane_is_ours(self.pane_info(sock)) or not self.client_in_front():
|
|
2628
|
+
return None
|
|
2629
|
+
rc, out = self.r.run(capture_argv(sock, self.st["pane"]), 10)
|
|
2630
|
+
screen = out if rc == 0 else ""
|
|
2631
|
+
if rc == 0:
|
|
2632
|
+
if not compacting(screen):
|
|
2633
|
+
self.memory_compact_since = None
|
|
2634
|
+
elif self.memory_compact_since is None:
|
|
2635
|
+
self.memory_compact_since = self.r.now()
|
|
2636
|
+
size = memory_banner(screen)
|
|
2637
|
+
if size:
|
|
2638
|
+
return "critical_memory", size, "pane"
|
|
2639
|
+
if self.cfg.memory_limit_mb > 0:
|
|
2640
|
+
used = self.r.footprint(self.pid)
|
|
2641
|
+
if used is not None and used >= self.cfg.memory_limit_mb * (1 << 20):
|
|
2642
|
+
return "memory_limit", format_size(used), "process"
|
|
2643
|
+
return None
|
|
2644
|
+
|
|
2645
|
+
def memory_update(self, found, now):
|
|
2646
|
+
"""Fold a memory_reading into the memory verdict: a new one is logged (detect), one
|
|
2647
|
+
that stands keeps its age and takes the newest size, and no reading ends it. Only the
|
|
2648
|
+
memory verdict's own state changes: a banner that comes and goes (a heap near the line,
|
|
2649
|
+
a pane read that timed out) never re-probes or re-logs a transcript verdict."""
|
|
2650
|
+
if found is None:
|
|
2651
|
+
if self.memory is not None:
|
|
2652
|
+
self.dbg("memory verdict cleared: the client is not at critical memory now")
|
|
2653
|
+
self.drop_memory()
|
|
2654
|
+
return
|
|
2655
|
+
code, size, src = found
|
|
2656
|
+
if self.memory is not None:
|
|
2657
|
+
self.memory.update(code=code, size=size, src=src)
|
|
2658
|
+
return
|
|
2659
|
+
self.memory = dict(_verdict("memory", code, now), size=size, src=src)
|
|
2660
|
+
self.memory_since, self.memory_busy_since = now, None
|
|
2661
|
+
self.memory_pace = Pacing()
|
|
2662
|
+
self.log("detect", **{"class": "memory", "code": code, "sid": self.sid,
|
|
2663
|
+
"acct": self.acct, "reset": "-", "size": size, "src": src})
|
|
2664
|
+
|
|
2665
|
+
def drop_memory(self):
|
|
2666
|
+
"""No memory verdict any more, and nothing it waited for or logged."""
|
|
2667
|
+
self.memory = self.memory_since = self.memory_busy_since = None
|
|
2668
|
+
self.memory_pace = Pacing()
|
|
2669
|
+
|
|
1717
2670
|
# -- deciding --
|
|
1718
2671
|
|
|
1719
2672
|
def subagents_busy(self, now):
|
|
@@ -1735,6 +2688,15 @@ class Watcher:
|
|
|
1735
2688
|
return newest > 0 and now - newest < self.cfg.subagent_quiet
|
|
1736
2689
|
return newest > 0 and now - newest < self.cfg.subagent_quiet
|
|
1737
2690
|
|
|
2691
|
+
def main_busy(self):
|
|
2692
|
+
"""What the main thread is in the middle of (main_thread_busy), or None: from the
|
|
2693
|
+
lines read so far, so only once they change."""
|
|
2694
|
+
if self.provider != "claude":
|
|
2695
|
+
return None
|
|
2696
|
+
if self.busy_memo is None or self.busy_memo[0] != self.lines_read:
|
|
2697
|
+
self.busy_memo = (self.lines_read, main_thread_busy(self.recent))
|
|
2698
|
+
return self.busy_memo[1]
|
|
2699
|
+
|
|
1738
2700
|
def session_cwd(self):
|
|
1739
2701
|
"""Where the probe and the relaunch run. claude: the registry's cwd first
|
|
1740
2702
|
(``--resume`` finds the transcript by it). codex: the launch directory first, so
|
|
@@ -1746,39 +2708,145 @@ class Watcher:
|
|
|
1746
2708
|
return cand
|
|
1747
2709
|
return None
|
|
1748
2710
|
|
|
1749
|
-
def build_relaunch_argv(self, cls):
|
|
1750
|
-
|
|
2711
|
+
def build_relaunch_argv(self, cls, detail=None):
|
|
2712
|
+
"""The relaunch argv, pinned to the model and effort the session last ran with."""
|
|
2713
|
+
return relaunch_argv(self.provider, self.argv, self.sid, cls, self.cfg.prompt,
|
|
2714
|
+
self.relaunch_settings(), detail)
|
|
2715
|
+
|
|
2716
|
+
def relaunch_settings(self):
|
|
2717
|
+
"""{"model", "effort"} the relaunch pins: this session's own, read from its history at
|
|
2718
|
+
decide time (a /model or /effort since the launch counts), or None. Fail-open: a
|
|
2719
|
+
session whose settings cannot be read relaunches exactly as it always has."""
|
|
2720
|
+
try:
|
|
2721
|
+
found = session_settings(self.provider, self.transcript)
|
|
2722
|
+
self.served_model = history_model(found)
|
|
2723
|
+
if not found.get("model") and not found.get("effort"):
|
|
2724
|
+
return None
|
|
2725
|
+
# The catalog only clamps an effort to the model and supplies Codex's default
|
|
2726
|
+
# one: this pool's copy and caches, the other pool's only when the chain named it.
|
|
2727
|
+
roots = {self.provider: self.st["acc_root"]}
|
|
2728
|
+
other = "codex" if self.provider == "claude" else "claude"
|
|
2729
|
+
if self.st.get("pool_" + other):
|
|
2730
|
+
roots[other] = self.st["pool_" + other]
|
|
2731
|
+
return resume_settings(self.provider, found, self.provider, load_catalog(roots))
|
|
2732
|
+
except Exception: # noqa: BLE001 — fail open: no settings, the launch's own options
|
|
2733
|
+
self.dbg("settings: " + traceback.format_exc().replace("\n", " | "))
|
|
2734
|
+
return None
|
|
2735
|
+
|
|
2736
|
+
def pace_of(self, verdict):
|
|
2737
|
+
"""``verdict``'s Pacing: the memory verdict's own, or the transcript's."""
|
|
2738
|
+
return self.memory_pace if verdict["class"] == "memory" else self.pace
|
|
1751
2739
|
|
|
1752
2740
|
def hold(self, verdict, **fields):
|
|
1753
|
-
|
|
1754
|
-
|
|
2741
|
+
pace = self.pace_of(verdict)
|
|
2742
|
+
if not pace.held:
|
|
2743
|
+
pace.held = True
|
|
1755
2744
|
self.log("hold", **dict({"class": verdict["class"], "sid": self.sid,
|
|
1756
2745
|
"acct": self.acct}, **fields))
|
|
1757
2746
|
|
|
2747
|
+
def waits_for_main(self, verdict, age):
|
|
2748
|
+
"""Whether ``verdict`` waits for the main thread's own work in flight (main_busy): a
|
|
2749
|
+
request, a reply still streaming, a tool one started. Stopping the client now would
|
|
2750
|
+
cut it off (a tool's processes die with it). An agent's account verdict waits up to
|
|
2751
|
+
SUBAGENT_CAP from its detection (``age``): the account accepted that work before the
|
|
2752
|
+
limit, and the main thread meets the limit on its next request anyway, whose rejection
|
|
2753
|
+
then stands in this one's place. A memory verdict waits as memory_waits says. Logged
|
|
2754
|
+
once, as hold reason=main-<request|stream|tool|compact>."""
|
|
2755
|
+
if verdict["class"] == "memory":
|
|
2756
|
+
busy = self.memory_waits()
|
|
2757
|
+
elif verdict.get("src") == "sidechain" and age < self.cfg.subagent_cap:
|
|
2758
|
+
busy = self.main_busy()
|
|
2759
|
+
else:
|
|
2760
|
+
busy = None
|
|
2761
|
+
if busy is None:
|
|
2762
|
+
return False
|
|
2763
|
+
pace = self.pace_of(verdict)
|
|
2764
|
+
if not pace.waited:
|
|
2765
|
+
pace.waited = True
|
|
2766
|
+
self.log("hold", **{"class": verdict["class"], "sid": self.sid, "acct": self.acct,
|
|
2767
|
+
"reason": "main-" + busy})
|
|
2768
|
+
return True
|
|
2769
|
+
|
|
2770
|
+
def memory_waits(self):
|
|
2771
|
+
"""What the memory verdict waits for, or None: the main thread's work in flight
|
|
2772
|
+
(main_busy), else a compaction under way, which only the pane's spinner shows
|
|
2773
|
+
(/compact is the banner's other remedy, and the transcript has nothing of it until it
|
|
2774
|
+
is done). Each wait is capped at MEMORY_BUSY_CAP -- the process may not last much
|
|
2775
|
+
longer -- counted from the first reading that found it, never from the detection: time
|
|
2776
|
+
the verdict spent held for want of room or behind a transcript verdict spends none of
|
|
2777
|
+
it, and work that starts later gets its own. The main thread found idle ends its wait."""
|
|
2778
|
+
now = self.r.now()
|
|
2779
|
+
busy = self.main_busy()
|
|
2780
|
+
if busy is None:
|
|
2781
|
+
self.memory_busy_since = None
|
|
2782
|
+
busy, since = "compact", self.memory_compact_since
|
|
2783
|
+
if since is None:
|
|
2784
|
+
return None
|
|
2785
|
+
else:
|
|
2786
|
+
if self.memory_busy_since is None:
|
|
2787
|
+
self.memory_busy_since = now
|
|
2788
|
+
since = self.memory_busy_since
|
|
2789
|
+
return busy if now - since < self.cfg.memory_busy_cap else None
|
|
2790
|
+
|
|
1758
2791
|
def decide(self):
|
|
1759
|
-
"""Act on the pending verdict once it has stood long enough; None = keep going.
|
|
2792
|
+
"""Act on the pending verdict once it has stood long enough; None = keep going. The
|
|
2793
|
+
transcript's verdict comes first; the memory verdict (watch_memory) is acted on only
|
|
2794
|
+
while there is none: a relaunch for any reason ends the process it is about."""
|
|
1760
2795
|
verdict = self.tracker.pending
|
|
1761
2796
|
now = self.r.now()
|
|
2797
|
+
memory = verdict is None
|
|
2798
|
+
if not memory:
|
|
2799
|
+
self.memory_busy_since = None # a wait behind this one is no wait for the main thread
|
|
2800
|
+
if self.provider == "claude" and not memory:
|
|
2801
|
+
# Its reset passed while it waited (the grace, a hold for want of room): the
|
|
2802
|
+
# limit is gone, and so is the reason to stop the session.
|
|
2803
|
+
dropped, promoted = self.tracker.expire(now)
|
|
2804
|
+
if dropped:
|
|
2805
|
+
self.dbg("quota verdict dropped: its reset has passed")
|
|
2806
|
+
self.pace.held, self.pace.probed = False, None
|
|
2807
|
+
if promoted is not None:
|
|
2808
|
+
self.on_event(("error", promoted), verdict)
|
|
2809
|
+
return None
|
|
2810
|
+
if memory:
|
|
2811
|
+
verdict = self.memory
|
|
2812
|
+
since = self.memory_since if memory else self.tracker.pending_since
|
|
1762
2813
|
cls = verdict["class"]
|
|
1763
|
-
age = now -
|
|
2814
|
+
age = now - since
|
|
1764
2815
|
need = self.cfg.grace
|
|
1765
2816
|
if cls == "transient":
|
|
1766
2817
|
need = max(need, ladder_wait(self.st["hist"], now, self.cfg.transient_ladder))
|
|
1767
2818
|
if age < need:
|
|
1768
2819
|
return None
|
|
2820
|
+
if verdict.get("src") == "sidechain" and (self.side_behind or self.tail.behind > 0):
|
|
2821
|
+
# Lines not read yet may answer it (a watcher restarted onto a session replays
|
|
2822
|
+
# hours of agent transcripts, SIDE_BYTES a tick): it is acted on once all are read.
|
|
2823
|
+
return None
|
|
2824
|
+
if memory and (self.tail is None or self.tail.behind > 0):
|
|
2825
|
+
return None # what the main thread is doing is read to the transcript's end first
|
|
1769
2826
|
ok, why = budget_check(cls, self.st["depth"], self.st["hist"], now, self.cfg)
|
|
1770
2827
|
if not ok:
|
|
1771
2828
|
if why == "depth":
|
|
1772
2829
|
self.giveup("depth", depth=self.st["depth"], sid=self.sid)
|
|
2830
|
+
if memory:
|
|
2831
|
+
# More of them in an hour than MEMORY_PER_HOUR: the session comes back as heavy
|
|
2832
|
+
# as it left (a resumed transcript can be). This process is left as it is, and
|
|
2833
|
+
# its watcher goes on with the transcript's verdicts.
|
|
2834
|
+
self.memory_off = True
|
|
2835
|
+
self.drop_memory()
|
|
2836
|
+
self.log("giveup", reason=why, sid=self.sid, acct=self.acct, **{"class": cls})
|
|
2837
|
+
return None
|
|
1773
2838
|
self.hold(verdict, reason=why)
|
|
1774
2839
|
return None
|
|
1775
2840
|
if cls in ("model", "transient") and age < self.cfg.subagent_cap \
|
|
1776
2841
|
and self.subagents_busy(now):
|
|
1777
2842
|
return None
|
|
1778
|
-
if self.
|
|
2843
|
+
if self.waits_for_main(verdict, age):
|
|
2844
|
+
return None
|
|
2845
|
+
pace = self.pace_of(verdict)
|
|
2846
|
+
if pace.probed is not None and now - pace.probed < self.cfg.hold_reprobe:
|
|
1779
2847
|
return None
|
|
1780
|
-
|
|
1781
|
-
argv = self.build_relaunch_argv(cls)
|
|
2848
|
+
pace.probed = now
|
|
2849
|
+
argv = self.build_relaunch_argv(cls, verdict.get("size") if memory else None)
|
|
1782
2850
|
if argv is None:
|
|
1783
2851
|
self.giveup("argv", sid=self.sid)
|
|
1784
2852
|
if cls in ROTATE:
|
|
@@ -1787,7 +2855,7 @@ class Watcher:
|
|
|
1787
2855
|
avoid = avoid_merge(self.st["avoid"], now)
|
|
1788
2856
|
cwd = self.session_cwd()
|
|
1789
2857
|
if cwd is None:
|
|
1790
|
-
self.
|
|
2858
|
+
return self.refuse(verdict, "cwd", sid=self.sid)
|
|
1791
2859
|
rc, out = self.r.run([self.st["self"]] + argv, self.cfg.probe_timeout,
|
|
1792
2860
|
env=probe_env(self.env, self.provider, avoid), cwd=cwd)
|
|
1793
2861
|
pick, tier = parse_probe(out)
|
|
@@ -1803,7 +2871,18 @@ class Watcher:
|
|
|
1803
2871
|
# The probe can take seconds: whatever the session wrote meanwhile (the user
|
|
1804
2872
|
# typing, auto-continue getting through) is read before anything is stopped.
|
|
1805
2873
|
self.read()
|
|
1806
|
-
if
|
|
2874
|
+
if not self.r.alive(self.pid):
|
|
2875
|
+
return None
|
|
2876
|
+
if memory:
|
|
2877
|
+
# ...and the pane is read once more: a transcript verdict now goes first, and the
|
|
2878
|
+
# banner must still be up (/compact may have brought the heap down meanwhile),
|
|
2879
|
+
# with the main thread still idle.
|
|
2880
|
+
if self.tracker.pending is not None:
|
|
2881
|
+
return None
|
|
2882
|
+
self.memory_update(self.memory_reading(), self.r.now())
|
|
2883
|
+
if self.memory is not verdict or self.waits_for_main(verdict, self.r.now() - since):
|
|
2884
|
+
return None
|
|
2885
|
+
elif self.tracker.pending is not verdict:
|
|
1807
2886
|
return None
|
|
1808
2887
|
return self.stop_and_relaunch(verdict, argv, avoid, probe_pick=pick)
|
|
1809
2888
|
|
|
@@ -1946,10 +3025,28 @@ class Watcher:
|
|
|
1946
3025
|
return False
|
|
1947
3026
|
self.r.sleep(min(0.2, self.cfg.poll))
|
|
1948
3027
|
|
|
3028
|
+
def refuse(self, verdict, why, **fields):
|
|
3029
|
+
"""A check refused to stop the session for ``verdict``, before anything was stopped.
|
|
3030
|
+
Any verdict but a memory one ends the watch, as it always has (giveup, ``fields`` as
|
|
3031
|
+
given). A memory verdict ends only itself: the client is left as it is and its limits,
|
|
3032
|
+
logins and crashes are still watched for. The refusal is logged once (giveup
|
|
3033
|
+
reason=memory-<why>), the verdict dropped, and the pane not read again for
|
|
3034
|
+
MEMORY_BACKOFF s (a check that refuses now mostly refuses on the next try too). None."""
|
|
3035
|
+
if verdict["class"] != "memory" or self.stopped:
|
|
3036
|
+
self.giveup(why, **fields) # raises
|
|
3037
|
+
logged = {"sid": self.sid, "acct": self.acct}
|
|
3038
|
+
logged.update((key, val) for key, val in fields.items() if key not in ("sid", "class"))
|
|
3039
|
+
logged["class"] = "memory"
|
|
3040
|
+
self.log("giveup", reason="memory-" + why, **logged)
|
|
3041
|
+
self.drop_memory()
|
|
3042
|
+
self.memory_compact_since = None
|
|
3043
|
+
self.next_memory = self.r.now() + max(self.cfg.memory_backoff, self.cfg.memory_scan)
|
|
3044
|
+
return None
|
|
3045
|
+
|
|
1949
3046
|
def stop_and_relaunch(self, verdict, argv, avoid, probe_pick="", kill=True):
|
|
1950
|
-
"""0 once the typed relaunch has consumed its token; GiveUp otherwise
|
|
1951
|
-
|
|
1952
|
-
relaunch."""
|
|
3047
|
+
"""0 once the typed relaunch has consumed its token; GiveUp otherwise -- or None, for
|
|
3048
|
+
a memory verdict a check refused (refuse). Every check that can refuse runs BEFORE
|
|
3049
|
+
the kill: a stopped session must always have its relaunch."""
|
|
1953
3050
|
cls = verdict["class"]
|
|
1954
3051
|
sock = tmux_socket(self.st["tmux"])
|
|
1955
3052
|
# 1. The pane must still be the shell that launched the shim — otherwise the
|
|
@@ -1958,39 +3055,37 @@ class Watcher:
|
|
|
1958
3055
|
# pane runs nu or tcsh would leave it stopped with nowhere to relaunch.
|
|
1959
3056
|
info = self.pane_info(sock) if sock else None
|
|
1960
3057
|
if not self.pane_is_ours(info):
|
|
1961
|
-
self.
|
|
3058
|
+
return self.refuse(verdict, "pane", sid=self.sid, **{"class": cls})
|
|
1962
3059
|
shell = self.r.proc_status(self.st["ppid"])
|
|
1963
3060
|
if shell is None or not is_shell(shell[2]):
|
|
1964
|
-
self.
|
|
3061
|
+
return self.refuse(verdict, "pane-shell", sid=self.sid, **{"class": cls})
|
|
1965
3062
|
# Only a client in the foreground of its terminal, and not stopped, is killed:
|
|
1966
3063
|
# one suspended with Ctrl-Z leaves another program in front, which the relaunch
|
|
1967
3064
|
# must not be typed into.
|
|
1968
3065
|
if kill:
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
if not client or "T" in flags or flags.startswith("t") \
|
|
1972
|
-
or ("+" not in flags and not self.cfg.test_tty):
|
|
1973
|
-
self.giveup("background", sid=self.sid, **{"class": cls})
|
|
3066
|
+
if not self.client_in_front():
|
|
3067
|
+
return self.refuse(verdict, "background", sid=self.sid, **{"class": cls})
|
|
1974
3068
|
# ...and only one that leads its own process group: a job of a job-control
|
|
1975
3069
|
# shell. Anything else (a client started by a script, `sh -c`, a pipeline
|
|
1976
3070
|
# stage) is not the prompt's job, and no prompt comes back when it ends.
|
|
1977
3071
|
# The suites' no-terminal knob waives this too: their harness has no job
|
|
1978
3072
|
# control to give the client a group of its own.
|
|
1979
3073
|
if not self.cfg.test_tty and self.r.pgid(self.pid) != self.pid:
|
|
1980
|
-
self.
|
|
3074
|
+
return self.refuse(verdict, "pgrp", sid=self.sid, **{"class": cls})
|
|
1981
3075
|
# One conversation, one client: another live process holding this session
|
|
1982
3076
|
# means stopping this one would not free it.
|
|
1983
3077
|
holder = self.other_holder()
|
|
1984
3078
|
if holder:
|
|
1985
|
-
self.
|
|
3079
|
+
return self.refuse(verdict, "holder", sid=self.sid, holder=holder,
|
|
3080
|
+
**{"class": cls})
|
|
1986
3081
|
cwd = self.session_cwd()
|
|
1987
3082
|
if cwd is None:
|
|
1988
|
-
self.
|
|
3083
|
+
return self.refuse(verdict, "cwd", sid=self.sid, **{"class": cls})
|
|
1989
3084
|
# Last, as it is the one check that touches the pane: synchronized panes, or a
|
|
1990
3085
|
# tmux mode that a cancel does not end, refuse.
|
|
1991
3086
|
why = self.pane_problem(sock, info)
|
|
1992
3087
|
if why:
|
|
1993
|
-
self.
|
|
3088
|
+
return self.refuse(verdict, why, sid=self.sid, **{"class": cls})
|
|
1994
3089
|
if self.switched_off():
|
|
1995
3090
|
raise GiveUp("off")
|
|
1996
3091
|
# 2. The relaunch file exists BEFORE the kill: a kill always has its relaunch.
|
|
@@ -2007,6 +3102,14 @@ class Watcher:
|
|
|
2007
3102
|
fields.update(chain_fields(self, avoid))
|
|
2008
3103
|
if receipt is not None:
|
|
2009
3104
|
fields["rollout_cursor"] = "%d:%d" % (receipt.ino, receipt.off)
|
|
3105
|
+
if self.provider == "claude" and self.st.get("via"):
|
|
3106
|
+
# what the stopped run carried, whichever session the client switched it to since
|
|
3107
|
+
# (/clear, /resume): an auth verdict parks that credential (bin/claude)
|
|
3108
|
+
fields["via"] = self.st["via"]
|
|
3109
|
+
if self.provider == "claude" and self.served_model:
|
|
3110
|
+
# --resume restores this model, so bin/claude's model-scoped fallback judges it, not
|
|
3111
|
+
# the account's default: only a session on the limited model itself gets pinned off it
|
|
3112
|
+
fields["model"] = self.served_model
|
|
2010
3113
|
if self.provider == "codex" and self.st.get("funding_tier") == "credits":
|
|
2011
3114
|
fields.update(funding_tier="credits", funding_pid=str(self.pid))
|
|
2012
3115
|
try:
|
|
@@ -2017,7 +3120,7 @@ class Watcher:
|
|
|
2017
3120
|
else:
|
|
2018
3121
|
paths = write_relaunch(self.ar_dir, token, fields, argv)
|
|
2019
3122
|
except (OSError, ValueError):
|
|
2020
|
-
self.
|
|
3123
|
+
return self.refuse(verdict, "write", sid=self.sid, **{"class": cls})
|
|
2021
3124
|
# 3. Stop the client (a crash needs no stopping).
|
|
2022
3125
|
if kill and not self.stop_process():
|
|
2023
3126
|
remove_quietly(*paths)
|
|
@@ -2063,11 +3166,23 @@ class Watcher:
|
|
|
2063
3166
|
remove_quietly(*paths)
|
|
2064
3167
|
self.giveup("prompt", sid=self.sid, **{"class": cls})
|
|
2065
3168
|
target = {"to_provider": self.cross_plan["provider"], "to_sid": target_sid}
|
|
3169
|
+
if cls == "memory" and verdict.get("size"):
|
|
3170
|
+
target["size"] = verdict["size"]
|
|
2066
3171
|
# 7.
|
|
2067
|
-
self.
|
|
2068
|
-
"depth": int(self.st["depth"]) + 1, "probe": probe_pick or "-"}, **target))
|
|
3172
|
+
self.log_switch(cls, argv, probe_pick, target)
|
|
2069
3173
|
return 0
|
|
2070
3174
|
|
|
3175
|
+
def log_switch(self, cls, argv, probe_pick, target):
|
|
3176
|
+
"""The switch line (``target``: its extra fields, a handoff's target or a memory
|
|
3177
|
+
verdict's size=). model=/effort= name what the relaunched client was told to run
|
|
3178
|
+
with, when it was told: the relaunch argv, or a handoff target's own."""
|
|
3179
|
+
ran = (self.cross_plan["provider"], self.cross_plan.get("argv") or []) \
|
|
3180
|
+
if self.cross_plan is not None else (self.provider, argv)
|
|
3181
|
+
switched = dict({"from": self.acct, "class": cls, "sid": self.sid,
|
|
3182
|
+
"depth": int(self.st["depth"]) + 1, "probe": probe_pick or "-"}, **target)
|
|
3183
|
+
switched.update(argv_settings(*ran))
|
|
3184
|
+
self.log("switch", **switched)
|
|
3185
|
+
|
|
2071
3186
|
def on_exit(self):
|
|
2072
3187
|
"""The client is gone and not by our hand. A clean exit (/exit, SIGHUP) deletes
|
|
2073
3188
|
claude's registry; a crash or SIGKILL leaves it behind — that is the only crash
|