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.
Files changed (150) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +30 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +82 -10
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +111 -1
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_origin.py +70 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +67 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. 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) or rollout (codex) and, on a verdict it can act on:
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 five are not
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) and
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
- """True for a main-thread user record a HUMAN produced (typed text, a slash command,
387
- a pasted image) — never a tool result, meta/compaction record, a harness
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 False
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 False
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 False
540
+ return None
404
541
  head = text.lstrip()
405
- if head.startswith(NON_HUMAN_PREFIXES):
406
- return False
407
- return not any(p and head.startswith(p) for p in prefixes)
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). No time filtering here — the tracker applies it."""
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
- return ("cancel", "user") if claude_human_input(rec, prefixes) else None
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
- return self.pending is not None or b'"isApiErrorMessage"' in raw
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 self.pending is None or (stamp is not None and stamp < self.since):
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 self.pending is None or self.pending["class"] != val["class"]:
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
- if i + 1 >= len(argv) or not _value_ok(argv[i + 1]):
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 relaunch_argv(provider, argv, sid, cls, prompt=None):
796
- """The argv the relaunched shim runs, built from the ORIGINAL argv.
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
- claude: the kept options + ``--resume <sid> <prompt>`` — no --model added: Claude
799
- restores the session's own model on --resume ([1m] included), and an explicit
800
- --model would switch that restore off.
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
- if kept is None:
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
- return None
815
- return ["resume"] + parsed[0] + [sid, text]
816
- return None
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.hold_logged = False
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
- if self.tracker.pending is not None:
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.clear()
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
- self.pump()
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
- # Any change of state re-arms the hold log and probes immediately.
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
- self.log("detect", **{"class": val["class"], "code": val["code"],
1619
- "sid": self.sid, "acct": self.acct,
1620
- "reset": val.get("reset") or "-"})
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
- return relaunch_argv(self.provider, self.argv, self.sid, cls, self.cfg.prompt)
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
- if not self.hold_logged:
1754
- self.hold_logged = True
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 - self.tracker.pending_since
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.last_probe is not None and now - self.last_probe < self.cfg.hold_reprobe:
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
- self.last_probe = now
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.giveup("cwd", sid=self.sid)
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 self.tracker.pending is not verdict or not self.r.alive(self.pid):
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. Every check
1951
- that can refuse runs BEFORE the kill: a stopped session must always have its
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.giveup("pane", sid=self.sid, **{"class": cls})
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.giveup("pane-shell", sid=self.sid, **{"class": cls})
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
- client = self.r.proc_status(self.pid)
1970
- flags = client[0] if client else ""
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.giveup("pgrp", sid=self.sid, **{"class": cls})
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.giveup("holder", sid=self.sid, holder=holder, **{"class": cls})
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.giveup("cwd", sid=self.sid, **{"class": cls})
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.giveup(why, sid=self.sid, **{"class": cls})
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.giveup("write", sid=self.sid, **{"class": cls})
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.log("switch", **dict({"from": self.acct, "class": cls, "sid": self.sid,
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