claude-dev-env 8.43.4 → 8.43.6

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 (36) hide show
  1. package/_shared/advisor/advisor-protocol.md +2 -2
  2. package/_shared/advisor/reference/cli-chain.md +9 -12
  3. package/_shared/advisor/reference/lifecycle.md +1 -1
  4. package/_shared/advisor/reference/third-party-bind.md +3 -3
  5. package/bin/install.mjs +39 -3
  6. package/bin/install.shared-settings.test.mjs +46 -0
  7. package/bin/install.test.mjs +1 -1
  8. package/docs/second-claude-account.md +11 -13
  9. package/package.json +1 -1
  10. package/scripts/_code_review_test_support.py +29 -39
  11. package/scripts/claude_account_worker.py +46 -297
  12. package/scripts/claude_account_worker_process.py +18 -154
  13. package/scripts/claude_account_worker_report.py +8 -4
  14. package/scripts/claude_chain_usage.py +7 -271
  15. package/scripts/codec_forwarding_test_support.py +11 -65
  16. package/scripts/dev_env_scripts_constants/claude_account_worker_constants.py +6 -24
  17. package/scripts/invoke_code_review.py +34 -56
  18. package/scripts/policy_lint/config/approved_test_pairs.py +1 -0
  19. package/scripts/resolve_worker_spawn.py +40 -72
  20. package/scripts/test_account_broker_guard.py +0 -4
  21. package/scripts/test_claude_account_worker.py +105 -350
  22. package/scripts/test_claude_chain_usage.py +50 -510
  23. package/scripts/test_dispatcher_profile_import.py +5 -9
  24. package/scripts/test_invoke_code_review.py +7 -6
  25. package/scripts/test_invoke_code_review_chain.py +26 -0
  26. package/scripts/test_invoke_code_review_cli.py +8 -8
  27. package/scripts/test_invoke_code_review_codec.py +3 -11
  28. package/scripts/test_invoke_code_review_contract.py +5 -1
  29. package/scripts/test_resolve_worker_spawn.py +118 -265
  30. package/scripts/test_resolve_worker_spawn_codec.py +9 -21
  31. package/scripts/tests/test_code_review_constants.py +2 -2
  32. package/scripts/tests/test_policy_lint_rules_pairing.py +13 -0
  33. package/scripts/claude_account_choice.py +0 -422
  34. package/scripts/claude_chain_runner.py +0 -1289
  35. package/scripts/test_claude_account_choice.py +0 -355
  36. package/scripts/test_claude_chain_runner.py +0 -1839
@@ -1,1289 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Run a ``claude`` invocation through a fallback chain of account binaries.
3
-
4
- An automation that shells out to a single ``claude -p ...`` fails outright when
5
- that account hits a usage limit. Other logged-in installs sit idle meanwhile.
6
- By default this module probes remaining weekly usage once per call, ranks chain
7
- accounts highest remaining first, and tries that order. It falls over to the
8
- next ranked binary only on a usage-limit failure. Every other outcome returns
9
- to the caller unchanged.
10
-
11
- Ordered-account mode (``--routing-mode ordered_account``) walks the chain in
12
- config order instead, still falling over only on a usage-limit signature.
13
- Authentication, timeout, and other non-usage failures stop immediately with
14
- ``terminal_status=advisor_blocked``.
15
-
16
- The chain lives in ``~/.claude/claude-chain.json``. Copy the committed
17
- ``claude-chain.example.json`` template there and list your account binaries.
18
- Default try order comes from weekly remaining via ``claude_chain_usage``
19
- (usage-pause OAuth probe), not from list position alone::
20
-
21
- {"chain": [{"command": "claude", "extra_args": []},
22
- {"command": "claude-profile-c", "extra_args": []}]}
23
-
24
- A usage-limited first try falls over to the next ranked binary::
25
-
26
- first try (highest remaining) -> exit 1, "usage limit reached" (falls over)
27
- next ranked binary -> exit 0 (served)
28
-
29
- When stdin is piped (not a TTY), the runner reads it once and forwards the
30
- same text to every chain attempt so a piped ``-p`` charter body reaches each
31
- binary in the walk::
32
-
33
- cat charter.md | python claude_chain_runner.py -- -p --strict-mcp-config
34
-
35
- Import ``run_claude`` for the outcome object, or run the module as a CLI::
36
-
37
- python claude_chain_runner.py [--timeout-seconds N]
38
- [--routing-mode usage_ranked|ordered_account] -- <claude args...>
39
- """
40
-
41
- from __future__ import annotations
42
-
43
- import argparse
44
- import importlib
45
- import io
46
- import json
47
- import os
48
- import subprocess
49
- import sys
50
- import tempfile
51
- import threading
52
- from collections.abc import Callable, Iterator, Sequence
53
- from contextlib import contextmanager
54
- from dataclasses import dataclass, field
55
- from pathlib import Path
56
- from types import ModuleType
57
- from typing import Protocol, TextIO
58
-
59
- if __name__ == "__main__":
60
- sys.modules.setdefault("claude_chain_runner", sys.modules[__name__])
61
-
62
- from dev_env_scripts_constants.claude_chain_constants import (
63
- AFFINITY_BINDING_COMMAND_MISSING_REASON,
64
- AFFINITY_BINDING_NOT_OBJECT_REASON,
65
- AFFINITY_BINDING_SESSION_ID_MISSING_REASON,
66
- AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON,
67
- AFFINITY_CORRUPT_MESSAGE_TEMPLATE,
68
- AFFINITY_JSON_INDENT_SPACES,
69
- AFFINITY_KEY_ALL_BINDINGS,
70
- AFFINITY_KEY_COMMAND,
71
- AFFINITY_KEY_SCHEMA_VERSION,
72
- AFFINITY_KEY_SESSION_ID,
73
- AFFINITY_MAXIMUM_ENTRIES,
74
- AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE,
75
- AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE,
76
- AFFINITY_STATE_FILENAME,
77
- AFFINITY_STATE_SCHEMA_VERSION,
78
- AFFINITY_TEMP_SUFFIX,
79
- AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON,
80
- AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE,
81
- AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE,
82
- ALL_ROUTING_MODES,
83
- RESUME_SESSION_FLAG,
84
- ALL_USAGE_LIMIT_SIGNATURES,
85
- ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND,
86
- ATTEMPT_STATUS_NONZERO_EXIT,
87
- ATTEMPT_STATUS_SERVED,
88
- ATTEMPT_STATUS_TIMEOUT,
89
- ATTEMPT_STATUS_USAGE_LIMITED,
90
- ATTEMPT_SUMMARY_ENTRY_TEMPLATE,
91
- ATTEMPT_SUMMARY_JOIN_SEPARATOR,
92
- CARRIAGE_RETURN,
93
- CHAIN_ADVISOR_BLOCKED_EXIT_CODE,
94
- CHAIN_CONFIG_ERROR_EXIT_CODE,
95
- CHAIN_EXHAUSTED_EXIT_CODE,
96
- CHAIN_EXHAUSTED_MESSAGE_TEMPLATE,
97
- CHAIN_USAGE_MODULE_NAME,
98
- CLAUDE_HOME_SUBDIRECTORY,
99
- CLI_ARGUMENTS_SEPARATOR,
100
- CLI_ROUTING_MODE_FLAG,
101
- CLI_TIMEOUT_FLAG,
102
- CODEC_ERROR_STRATEGY,
103
- CONFIG_CHAIN_EMPTY_REASON,
104
- CONFIG_CHAIN_KEY,
105
- CONFIG_CHAIN_NOT_LIST_REASON,
106
- CONFIG_COMMAND_KEY,
107
- CONFIG_CREDENTIALS_PATH_KEY,
108
- CONFIG_ENTRY_COMMAND_MISSING_REASON,
109
- CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON,
110
- CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON,
111
- CONFIG_ENTRY_NOT_OBJECT_REASON,
112
- CONFIG_EXTRA_ARGS_KEY,
113
- CONFIG_FILENAME,
114
- CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE,
115
- CONFIG_MALFORMED_MESSAGE_TEMPLATE,
116
- CONFIG_MISSING_MESSAGE_TEMPLATE,
117
- CONFIG_NOT_OBJECT_REASON,
118
- CONFIG_UNREADABLE_MESSAGE_TEMPLATE,
119
- CRLF_NEWLINE,
120
- DEFAULT_ROUTING_MODE,
121
- DEFAULT_TIMEOUT_SECONDS,
122
- EXAMPLE_CONFIG_FILENAME,
123
- LINE_FEED,
124
- NO_COMPLETED_PROCESS_RETURN_CODE,
125
- ROUTING_MODE_ORDERED_ACCOUNT,
126
- SESSION_ID_JSON_KEY,
127
- TERMINAL_STATUS_ADVISOR_BLOCKED,
128
- TERMINAL_STATUS_CHAIN_EXHAUSTED,
129
- TERMINAL_STATUS_SERVED,
130
- TERMINAL_STATUS_TIMEOUT,
131
- UTF8_ENCODING,
132
- )
133
-
134
- from subprocess_window_access import hidden_window_creation_flags
135
-
136
-
137
- def _decode_captured_stream(raw_bytes: bytes, encoding: str, errors: str) -> str:
138
- """Decode captured *raw_bytes* with ``text=True`` universal-newline semantics.
139
-
140
- Spool capture writes binary temp files, so a bare ``.decode`` leaves CRLF and
141
- bare CR intact. ``subprocess.run(..., text=True)`` normalized those to LF;
142
- this helper restores that contract for Windows children that emit ``\\r\\n``.
143
- """
144
- decoded_text = raw_bytes.decode(encoding, errors)
145
- return decoded_text.replace(CRLF_NEWLINE, LINE_FEED).replace(
146
- CARRIAGE_RETURN, LINE_FEED
147
- )
148
-
149
-
150
- class _SpooledByteStream(Protocol):
151
- """Binary spool with seek/read — TemporaryFile wrappers and BufferedIO."""
152
-
153
- def seek(self, target: int, whence: int = 0, /) -> int: ...
154
-
155
- def read(self, size: int | None = -1, /) -> bytes: ...
156
-
157
-
158
- def _decoded_spooled_streams(
159
- stdout_file: _SpooledByteStream,
160
- stderr_file: _SpooledByteStream,
161
- encoding: str,
162
- errors: str,
163
- ) -> tuple[str, str]:
164
- """Seek both spool files to the start and decode their full contents."""
165
- stdout_file.seek(0)
166
- stderr_file.seek(0)
167
- return (
168
- _decode_captured_stream(stdout_file.read(), encoding, errors),
169
- _decode_captured_stream(stderr_file.read(), encoding, errors),
170
- )
171
-
172
-
173
- def _attach_partial_timeout_streams(
174
- timeout_error: subprocess.TimeoutExpired,
175
- stdout_file: _SpooledByteStream,
176
- stderr_file: _SpooledByteStream,
177
- encoding: str,
178
- errors: str,
179
- ) -> None:
180
- """Decode partial spool contents onto *timeout_error* before re-raise."""
181
- captured_stdout, captured_stderr = _decoded_spooled_streams(
182
- stdout_file, stderr_file, encoding, errors
183
- )
184
- timeout_error.stdout = captured_stdout
185
- timeout_error.stderr = captured_stderr
186
-
187
-
188
- # Capturing a large-output child through OS pipes (``capture_output=True``)
189
- # deadlocks on Windows: the child buffers its whole response and flushes it at
190
- # once, the pipe buffer fills before the parent drains it, and both sides block.
191
- # Redirecting each stream to a temporary file removes the pipe, so the child
192
- # writes freely; the files are then read back and decoded the way a pipe capture
193
- # would. ``capture_output``, ``text``, and ``env`` are ignored in favor of file
194
- # redirection and the parent environment; ``timeout``, ``check``, ``cwd``,
195
- # ``stdin``, ``input``, ``encoding``, and ``errors`` are honored. On timeout,
196
- # partial stdout/stderr are decoded from the temp files and attached to the
197
- # raised ``TimeoutExpired``. When ``check=True`` and the child exits non-zero,
198
- # ``CalledProcessError.stdout`` / ``.stderr`` stay unset (temp files are not
199
- # attached to that raised error).
200
- def _run_captured_subprocess(
201
- all_invocation_tokens: list[str],
202
- **all_subprocess_options: object,
203
- ) -> subprocess.CompletedProcess[str]:
204
- """Run *all_invocation_tokens*, spooling stdout and stderr to temp files."""
205
- encoding = str(all_subprocess_options.get("encoding") or UTF8_ENCODING)
206
- errors = str(all_subprocess_options.get("errors") or CODEC_ERROR_STRATEGY)
207
- input_bytes = _captured_stdin_bytes(all_subprocess_options, encoding, errors)
208
- working_directory = all_subprocess_options.get("cwd")
209
- timeout_seconds = all_subprocess_options.get("timeout")
210
- with (
211
- tempfile.TemporaryFile() as stdout_file,
212
- tempfile.TemporaryFile() as stderr_file,
213
- ):
214
- try:
215
- completion = subprocess.run(
216
- all_invocation_tokens,
217
- stdout=stdout_file,
218
- stderr=stderr_file,
219
- cwd=working_directory if isinstance(working_directory, str) else None,
220
- check=bool(all_subprocess_options.get("check", False)),
221
- timeout=(
222
- float(timeout_seconds)
223
- if isinstance(timeout_seconds, (int, float))
224
- else None
225
- ),
226
- input=input_bytes,
227
- creationflags=hidden_window_creation_flags(),
228
- )
229
- except subprocess.TimeoutExpired as timeout_error:
230
- _attach_partial_timeout_streams(
231
- timeout_error, stdout_file, stderr_file, encoding, errors
232
- )
233
- raise
234
- captured_stdout, captured_stderr = _decoded_spooled_streams(
235
- stdout_file, stderr_file, encoding, errors
236
- )
237
- return subprocess.CompletedProcess(
238
- all_invocation_tokens,
239
- completion.returncode,
240
- captured_stdout,
241
- captured_stderr,
242
- )
243
-
244
-
245
- def _captured_stdin_bytes(
246
- all_subprocess_options: dict[str, object], encoding: str, errors: str
247
- ) -> bytes | None:
248
- """Return the bytes to feed the child's stdin for a spooled run.
249
-
250
- ::
251
-
252
- stdin=<open prompt file> -> the file's bytes
253
- stdin=subprocess.DEVNULL -> b"" (an immediate EOF)
254
- input="charter text" -> the encoded text
255
-
256
- A wrapper hands the runner a ``stdin`` stream or a ``DEVNULL`` sentinel; the
257
- spooled run reads from an ``input`` pipe rather than the caller's handle, so
258
- the stream is read into bytes here to deliver the same stdin a direct pipe
259
- would. When no ``stdin`` is given, the ``input`` text is encoded instead.
260
- """
261
- stdin_source = all_subprocess_options.get("stdin")
262
- if isinstance(stdin_source, io.TextIOBase):
263
- return stdin_source.read().encode(encoding, errors)
264
- if isinstance(stdin_source, (io.RawIOBase, io.BufferedIOBase)):
265
- return stdin_source.read() or b""
266
- if isinstance(stdin_source, int):
267
- return b""
268
- input_text = all_subprocess_options.get("input")
269
- if input_text is None:
270
- return None
271
- return str(input_text).encode(encoding, errors)
272
-
273
-
274
- class ChainConfigurationError(Exception):
275
- """Raised when the chain configuration is missing, unreadable, or malformed."""
276
-
277
-
278
- @dataclass(frozen=True)
279
- class ChainEntry:
280
- """One binary in the fallback chain and its per-account extra arguments.
281
-
282
- ``credentials_path`` is an optional path to that account's OAuth credentials
283
- file. The subprocess walk does not pass it; weekly-usage ranking reads it
284
- when present.
285
- """
286
-
287
- command: str
288
- extra_args: tuple[str, ...]
289
- credentials_path: str | None = None
290
-
291
-
292
- @dataclass(frozen=True)
293
- class ChainAttempt:
294
- """Record of one binary invocation and how it resolved."""
295
-
296
- command: str
297
- status: str
298
-
299
-
300
- @dataclass(frozen=True)
301
- class ChainInvocationOutcome:
302
- """Outcome of one chain walk: who served, how it ended, optional session id.
303
-
304
- ::
305
-
306
- zero exit with JSON session_id
307
- -> served_command set, terminal_status=served, session_id filled
308
- ordered_account auth/timeout/generic process error
309
- -> served_command=None, terminal_status=advisor_blocked
310
- usage_ranked TimeoutExpired mid-walk
311
- -> served_command=None, terminal_status=timeout
312
- every entry usage-limited or missing
313
- -> served_command=None, terminal_status=chain_exhausted
314
-
315
- ``attempts`` lists every binary tried. Callers resume later consults with
316
- ``session_id`` when the bind returned one.
317
- """
318
-
319
- served_command: str | None
320
- returncode: int
321
- stdout: str
322
- stderr: str
323
- attempts: tuple[ChainAttempt, ...]
324
- terminal_status: str
325
- session_id: str | None = None
326
-
327
-
328
- class WeeklyUsageAccountReport(Protocol):
329
- """Minimal account-report surface the runner needs for ranking and mapping."""
330
-
331
- command: str
332
-
333
-
334
- chain_subprocess_runner = _run_captured_subprocess
335
- _shared_chain_subprocess_lock = threading.Lock()
336
-
337
-
338
- def chain_subprocess_runner_lock() -> threading.Lock:
339
- """Return the lock for adapters that temporarily configure the runner."""
340
- return _shared_chain_subprocess_lock
341
-
342
-
343
- @contextmanager
344
- def override_chain_subprocess_runner(
345
- replacement_runner: Callable[..., subprocess.CompletedProcess[str]],
346
- ) -> Iterator[Callable[..., subprocess.CompletedProcess[str]]]:
347
- """Temporarily replace the chain subprocess seam under its shared lock.
348
-
349
- Args:
350
- replacement_runner: Callable used for subprocess invocations during the
351
- context.
352
-
353
- Yields:
354
- The runner that was active before the replacement.
355
- """
356
- global chain_subprocess_runner
357
- with chain_subprocess_runner_lock():
358
- previous_runner = chain_subprocess_runner
359
- chain_subprocess_runner = replacement_runner
360
- try:
361
- yield previous_runner
362
- finally:
363
- chain_subprocess_runner = previous_runner
364
-
365
-
366
- def _load_chain_usage_module() -> ModuleType:
367
- return importlib.import_module(CHAIN_USAGE_MODULE_NAME)
368
-
369
-
370
- def _default_chain_weekly_usage_reporter(
371
- *, config_path: Path
372
- ) -> list[WeeklyUsageAccountReport]:
373
- usage_module = _load_chain_usage_module()
374
- return usage_module.report_chain_weekly_usage(config_path=config_path)
375
-
376
-
377
- chain_weekly_usage_reporter: Callable[..., list[WeeklyUsageAccountReport]] = (
378
- _default_chain_weekly_usage_reporter
379
- )
380
-
381
-
382
- def chain_config_path() -> Path:
383
- """Return the path to the per-user chain configuration file."""
384
- return Path.home() / CLAUDE_HOME_SUBDIRECTORY / CONFIG_FILENAME
385
-
386
-
387
- def _invalid_shape_error(config_path: Path, reason: str) -> ChainConfigurationError:
388
- return ChainConfigurationError(
389
- CONFIG_INVALID_SHAPE_MESSAGE_TEMPLATE.format(
390
- config_path=config_path,
391
- reason=reason,
392
- example_filename=EXAMPLE_CONFIG_FILENAME,
393
- )
394
- )
395
-
396
-
397
- def _coerce_extra_args(raw_extra_args: object, config_path: Path) -> tuple[str, ...]:
398
- if not isinstance(raw_extra_args, list) or not all(
399
- isinstance(each_argument, str) for each_argument in raw_extra_args
400
- ):
401
- raise _invalid_shape_error(config_path, CONFIG_ENTRY_EXTRA_ARGS_INVALID_REASON)
402
- return tuple(raw_extra_args)
403
-
404
-
405
- def _coerce_credentials_path(
406
- raw_credentials_path: object, config_path: Path
407
- ) -> str | None:
408
- if raw_credentials_path is None:
409
- return None
410
- if not isinstance(raw_credentials_path, str) or not raw_credentials_path:
411
- raise _invalid_shape_error(
412
- config_path, CONFIG_ENTRY_CREDENTIALS_PATH_INVALID_REASON
413
- )
414
- return raw_credentials_path
415
-
416
-
417
- def _parse_chain_entry(raw_entry: object, config_path: Path) -> ChainEntry:
418
- if not isinstance(raw_entry, dict):
419
- raise _invalid_shape_error(config_path, CONFIG_ENTRY_NOT_OBJECT_REASON)
420
- command = raw_entry.get(CONFIG_COMMAND_KEY)
421
- if not isinstance(command, str) or not command:
422
- raise _invalid_shape_error(config_path, CONFIG_ENTRY_COMMAND_MISSING_REASON)
423
- extra_args = _coerce_extra_args(
424
- raw_entry.get(CONFIG_EXTRA_ARGS_KEY, []), config_path
425
- )
426
- credentials_path = _coerce_credentials_path(
427
- raw_entry.get(CONFIG_CREDENTIALS_PATH_KEY), config_path
428
- )
429
- return ChainEntry(
430
- command=command,
431
- extra_args=extra_args,
432
- credentials_path=credentials_path,
433
- )
434
-
435
-
436
- def _parse_chain_entries(parsed_config: object, config_path: Path) -> list[ChainEntry]:
437
- if not isinstance(parsed_config, dict):
438
- raise _invalid_shape_error(config_path, CONFIG_NOT_OBJECT_REASON)
439
- raw_chain = parsed_config.get(CONFIG_CHAIN_KEY)
440
- if not isinstance(raw_chain, list):
441
- raise _invalid_shape_error(config_path, CONFIG_CHAIN_NOT_LIST_REASON)
442
- if not raw_chain:
443
- raise _invalid_shape_error(config_path, CONFIG_CHAIN_EMPTY_REASON)
444
- return [
445
- _parse_chain_entry(each_raw_entry, config_path) for each_raw_entry in raw_chain
446
- ]
447
-
448
-
449
- def load_chain(config_path: Path) -> list[ChainEntry]:
450
- """Load the ordered fallback chain from *config_path*.
451
-
452
- Args:
453
- config_path: Path to the chain configuration JSON file.
454
-
455
- Returns:
456
- The ordered list of chain entries the file declares.
457
-
458
- Raises:
459
- ChainConfigurationError: When the file is absent, unreadable, not valid
460
- JSON, or does not match the expected shape.
461
- """
462
- if not config_path.is_file():
463
- raise ChainConfigurationError(
464
- CONFIG_MISSING_MESSAGE_TEMPLATE.format(
465
- config_path=config_path, example_filename=EXAMPLE_CONFIG_FILENAME
466
- )
467
- )
468
- try:
469
- raw_text = config_path.read_text(encoding=UTF8_ENCODING)
470
- except OSError as read_error:
471
- raise ChainConfigurationError(
472
- CONFIG_UNREADABLE_MESSAGE_TEMPLATE.format(
473
- config_path=config_path,
474
- error=read_error,
475
- example_filename=EXAMPLE_CONFIG_FILENAME,
476
- )
477
- ) from read_error
478
- try:
479
- parsed_config = json.loads(raw_text)
480
- except json.JSONDecodeError as decode_error:
481
- raise ChainConfigurationError(
482
- CONFIG_MALFORMED_MESSAGE_TEMPLATE.format(
483
- config_path=config_path,
484
- error=decode_error,
485
- example_filename=EXAMPLE_CONFIG_FILENAME,
486
- )
487
- ) from decode_error
488
- return _parse_chain_entries(parsed_config, config_path)
489
-
490
-
491
- def _build_invocation(entry: ChainEntry, all_claude_arguments: list[str]) -> list[str]:
492
- return [entry.command, *all_claude_arguments, *entry.extra_args]
493
-
494
-
495
- def _entries_ranked_by_weekly_remaining(
496
- all_entries: list[ChainEntry],
497
- all_usage_reports: Sequence[WeeklyUsageAccountReport],
498
- ) -> list[ChainEntry]:
499
- usage_module = _load_chain_usage_module()
500
- entries_by_command: dict[str, list[ChainEntry]] = {}
501
- for each_entry in all_entries:
502
- entries_by_command.setdefault(each_entry.command, []).append(each_entry)
503
- all_ranked_reports = usage_module.rank_accounts_by_weekly_remaining(
504
- list(all_usage_reports)
505
- )
506
- ranked_entries: list[ChainEntry] = []
507
- seen_commands: set[str] = set()
508
- for each_report in all_ranked_reports:
509
- if each_report.command in seen_commands:
510
- continue
511
- matched_entries = entries_by_command.get(each_report.command)
512
- if matched_entries is None:
513
- continue
514
- seen_commands.add(each_report.command)
515
- ranked_entries.extend(matched_entries)
516
- for each_entry in all_entries:
517
- if each_entry.command not in seen_commands:
518
- ranked_entries.append(each_entry)
519
- return ranked_entries
520
-
521
-
522
- def _is_usage_limit_failure(completion: subprocess.CompletedProcess[str]) -> bool:
523
- combined_text = f"{completion.stdout}{completion.stderr}".lower()
524
- return any(
525
- each_signature in combined_text for each_signature in ALL_USAGE_LIMIT_SIGNATURES
526
- )
527
-
528
-
529
- def extract_session_id_from_stdout(stdout_text: str) -> str | None:
530
- """Return the first ``session_id`` found in Claude JSON stdout.
531
-
532
- ::
533
-
534
- '{"type":"result","session_id":"abc","result":"ok"}'
535
- -> "abc"
536
- 'not json'
537
- -> None
538
-
539
- Accepts a single JSON object or NDJSON event lines. The first non-empty
540
- string value under the ``session_id`` key wins.
541
-
542
- Args:
543
- stdout_text: Captured stdout from a Claude ``--output-format json`` run.
544
-
545
- Returns:
546
- The session id string, or ``None`` when none is present.
547
- """
548
- stripped_stdout = stdout_text.strip()
549
- if not stripped_stdout:
550
- return None
551
- maybe_session_id = _session_id_from_json_text(stripped_stdout)
552
- if maybe_session_id is not None:
553
- return maybe_session_id
554
- for each_line in stripped_stdout.splitlines():
555
- stripped_line = each_line.strip()
556
- if not stripped_line:
557
- continue
558
- maybe_session_id = _session_id_from_json_text(stripped_line)
559
- if maybe_session_id is not None:
560
- return maybe_session_id
561
- return None
562
-
563
-
564
- def _session_id_from_json_text(json_text: str) -> str | None:
565
- try:
566
- parsed_payload = json.loads(json_text)
567
- except json.JSONDecodeError:
568
- return None
569
- if not isinstance(parsed_payload, dict):
570
- return None
571
- raw_session_id = parsed_payload.get(SESSION_ID_JSON_KEY)
572
- if isinstance(raw_session_id, str) and raw_session_id:
573
- return raw_session_id
574
- return None
575
-
576
-
577
- @dataclass(frozen=True)
578
- class AffinityBinding:
579
- """One session-id to chain-binary binding."""
580
-
581
- session_id: str
582
- command: str
583
-
584
-
585
- @dataclass(frozen=True)
586
- class AffinityStore:
587
- """Versioned, bounded session-to-binary affinity document."""
588
-
589
- schema_version: int = AFFINITY_STATE_SCHEMA_VERSION
590
- all_bindings: list[AffinityBinding] = field(default_factory=list)
591
-
592
-
593
- def default_affinity_state_path(claude_home_directory: Path) -> Path:
594
- """Return the default affinity state path under a Claude home directory.
595
-
596
- Args:
597
- claude_home_directory: Claude configuration root (for example ``~/.claude``).
598
-
599
- Returns:
600
- Path to the affinity state JSON file.
601
- """
602
- return claude_home_directory / AFFINITY_STATE_FILENAME
603
-
604
-
605
- def _affinity_corrupt_error(state_path: Path, error: object) -> ValueError:
606
- return ValueError(
607
- AFFINITY_CORRUPT_MESSAGE_TEMPLATE.format(
608
- state_path=state_path,
609
- error=error,
610
- )
611
- )
612
-
613
-
614
- def _parse_affinity_binding(
615
- each_binding: object,
616
- *,
617
- state_path: Path,
618
- ) -> AffinityBinding:
619
- if not isinstance(each_binding, dict):
620
- raise _affinity_corrupt_error(state_path, AFFINITY_BINDING_NOT_OBJECT_REASON)
621
- session_id = each_binding.get(AFFINITY_KEY_SESSION_ID)
622
- command = each_binding.get(AFFINITY_KEY_COMMAND)
623
- if not isinstance(session_id, str) or not session_id:
624
- raise _affinity_corrupt_error(
625
- state_path, AFFINITY_BINDING_SESSION_ID_MISSING_REASON
626
- )
627
- if not isinstance(command, str) or not command:
628
- raise _affinity_corrupt_error(
629
- state_path, AFFINITY_BINDING_COMMAND_MISSING_REASON
630
- )
631
- return AffinityBinding(session_id=session_id, command=command)
632
-
633
-
634
- def _bindings_from_payload(
635
- all_payload_fields: dict[str, object],
636
- *,
637
- state_path: Path,
638
- ) -> list[AffinityBinding]:
639
- schema_version = all_payload_fields.get(AFFINITY_KEY_SCHEMA_VERSION)
640
- if schema_version != AFFINITY_STATE_SCHEMA_VERSION:
641
- raise _affinity_corrupt_error(
642
- state_path,
643
- AFFINITY_UNSUPPORTED_SCHEMA_VERSION_REASON_TEMPLATE.format(
644
- schema_version=schema_version
645
- ),
646
- )
647
- raw_bindings = all_payload_fields.get(AFFINITY_KEY_ALL_BINDINGS)
648
- if not isinstance(raw_bindings, list):
649
- raise _affinity_corrupt_error(
650
- state_path, AFFINITY_BINDINGS_MISSING_OR_NOT_LIST_REASON
651
- )
652
- return [
653
- _parse_affinity_binding(each_binding, state_path=state_path)
654
- for each_binding in raw_bindings
655
- ]
656
-
657
-
658
- def load_affinity_store(state_path: Path) -> AffinityStore:
659
- """Load a versioned affinity store, or an empty store when the file is absent.
660
-
661
- Args:
662
- state_path: Path to the affinity state JSON file.
663
-
664
- Returns:
665
- Parsed affinity store.
666
-
667
- Raises:
668
- ValueError: When the document is corrupt or uses an unsupported schema.
669
- """
670
- if not state_path.is_file():
671
- return AffinityStore()
672
- try:
673
- raw_text = state_path.read_text(encoding=UTF8_ENCODING)
674
- parsed_payload = json.loads(raw_text)
675
- except (OSError, json.JSONDecodeError, UnicodeError) as load_error:
676
- raise _affinity_corrupt_error(state_path, load_error) from load_error
677
- if not isinstance(parsed_payload, dict):
678
- raise _affinity_corrupt_error(
679
- state_path, AFFINITY_TOP_LEVEL_NOT_OBJECT_REASON
680
- )
681
- return AffinityStore(
682
- schema_version=AFFINITY_STATE_SCHEMA_VERSION,
683
- all_bindings=_bindings_from_payload(parsed_payload, state_path=state_path),
684
- )
685
-
686
-
687
- def record_affinity_binding(
688
- store: AffinityStore,
689
- *,
690
- session_id: str,
691
- command: str,
692
- maximum_entries: int = AFFINITY_MAXIMUM_ENTRIES,
693
- ) -> AffinityStore:
694
- """Return a new store with ``session_id`` bound to ``command``, bounded.
695
-
696
- Re-binding an existing session moves it to the newest end. When the store
697
- exceeds ``maximum_entries``, the oldest bindings drop first.
698
-
699
- Args:
700
- store: Current affinity store.
701
- session_id: Claude session id to bind.
702
- command: Chain binary command that served the session.
703
- maximum_entries: Hard cap on retained bindings.
704
-
705
- Returns:
706
- Updated store (does not mutate ``store``).
707
-
708
- Raises:
709
- ValueError: When session_id, command, or maximum_entries is invalid.
710
- """
711
- if not session_id or not command:
712
- raise ValueError(AFFINITY_SESSION_ID_AND_COMMAND_REQUIRED_MESSAGE)
713
- if maximum_entries < 1:
714
- raise ValueError(AFFINITY_MAXIMUM_ENTRIES_MINIMUM_MESSAGE)
715
- all_remaining = [
716
- each_binding
717
- for each_binding in store.all_bindings
718
- if each_binding.session_id != session_id
719
- ]
720
- all_remaining.append(AffinityBinding(session_id=session_id, command=command))
721
- if len(all_remaining) > maximum_entries:
722
- all_remaining = all_remaining[-maximum_entries:]
723
- return AffinityStore(
724
- schema_version=AFFINITY_STATE_SCHEMA_VERSION,
725
- all_bindings=all_remaining,
726
- )
727
-
728
-
729
- def save_affinity_store_atomic(state_path: Path, store: AffinityStore) -> None:
730
- """Atomically replace the affinity state file with ``store``.
731
-
732
- Creates a unique sibling temporary file via ``tempfile.mkstemp``, writes
733
- and flushes the document, then uses ``os.replace`` so readers never
734
- observe a partial document.
735
-
736
- Args:
737
- state_path: Destination affinity state path.
738
- store: Store document to persist.
739
-
740
- Raises:
741
- OSError: When the write or replace fails (message is actionable).
742
- """
743
- payload = {
744
- AFFINITY_KEY_SCHEMA_VERSION: store.schema_version,
745
- AFFINITY_KEY_ALL_BINDINGS: [
746
- {
747
- AFFINITY_KEY_SESSION_ID: each_binding.session_id,
748
- AFFINITY_KEY_COMMAND: each_binding.command,
749
- }
750
- for each_binding in store.all_bindings
751
- ],
752
- }
753
- serialized_document = (
754
- json.dumps(payload, indent=AFFINITY_JSON_INDENT_SPACES, sort_keys=True)
755
- + "\n"
756
- )
757
- state_path.parent.mkdir(parents=True, exist_ok=True)
758
- file_descriptor, temporary_name = tempfile.mkstemp(
759
- prefix=f".{state_path.name}.",
760
- suffix=AFFINITY_TEMP_SUFFIX,
761
- dir=state_path.parent,
762
- )
763
- temporary_path = Path(temporary_name)
764
- owned_descriptor = file_descriptor
765
- try:
766
- with os.fdopen(file_descriptor, "w", encoding=UTF8_ENCODING) as temporary_file:
767
- owned_descriptor = -1
768
- temporary_file.write(serialized_document)
769
- temporary_file.flush()
770
- os.fsync(temporary_file.fileno())
771
- os.replace(temporary_path, state_path)
772
- except OSError as write_error:
773
- if owned_descriptor >= 0:
774
- try:
775
- os.close(owned_descriptor)
776
- except OSError:
777
- pass
778
- try:
779
- temporary_path.unlink(missing_ok=True)
780
- except OSError:
781
- pass
782
- raise OSError(
783
- AFFINITY_WRITE_FAILED_MESSAGE_TEMPLATE.format(
784
- state_path=state_path,
785
- error=write_error,
786
- )
787
- ) from write_error
788
-
789
-
790
- def _served_outcome(
791
- served_command: str,
792
- completion: subprocess.CompletedProcess[str],
793
- all_attempts: list[ChainAttempt],
794
- ) -> ChainInvocationOutcome:
795
- maybe_session_id = None
796
- if completion.returncode == 0:
797
- maybe_session_id = extract_session_id_from_stdout(completion.stdout)
798
- return ChainInvocationOutcome(
799
- served_command=served_command,
800
- returncode=completion.returncode,
801
- stdout=completion.stdout,
802
- stderr=completion.stderr,
803
- attempts=tuple(all_attempts),
804
- terminal_status=TERMINAL_STATUS_SERVED,
805
- session_id=maybe_session_id,
806
- )
807
-
808
-
809
- def _timeout_streams(
810
- timeout_error: subprocess.TimeoutExpired | None,
811
- ) -> tuple[str, str]:
812
- if timeout_error is None:
813
- return "", ""
814
- captured_stdout = (
815
- timeout_error.stdout if isinstance(timeout_error.stdout, str) else ""
816
- )
817
- captured_stderr = (
818
- timeout_error.stderr if isinstance(timeout_error.stderr, str) else ""
819
- )
820
- return captured_stdout, captured_stderr
821
-
822
-
823
- def _no_process_outcome(
824
- all_attempts: list[ChainAttempt],
825
- timeout_error: subprocess.TimeoutExpired | None,
826
- *,
827
- terminal_status: str,
828
- ) -> ChainInvocationOutcome:
829
- captured_stdout, captured_stderr = _timeout_streams(timeout_error)
830
- return ChainInvocationOutcome(
831
- served_command=None,
832
- returncode=NO_COMPLETED_PROCESS_RETURN_CODE,
833
- stdout=captured_stdout,
834
- stderr=captured_stderr,
835
- attempts=tuple(all_attempts),
836
- terminal_status=terminal_status,
837
- session_id=None,
838
- )
839
-
840
-
841
- def _advisor_blocked_outcome(
842
- completion: subprocess.CompletedProcess[str],
843
- all_attempts: list[ChainAttempt],
844
- ) -> ChainInvocationOutcome:
845
- return ChainInvocationOutcome(
846
- served_command=None,
847
- returncode=completion.returncode,
848
- stdout=completion.stdout,
849
- stderr=completion.stderr,
850
- attempts=tuple(all_attempts),
851
- terminal_status=TERMINAL_STATUS_ADVISOR_BLOCKED,
852
- session_id=None,
853
- )
854
-
855
-
856
- def _exhausted_outcome(
857
- all_attempts: list[ChainAttempt],
858
- last_usage_limited: subprocess.CompletedProcess[str] | None,
859
- ) -> ChainInvocationOutcome:
860
- if last_usage_limited is None:
861
- return _no_process_outcome(
862
- all_attempts,
863
- None,
864
- terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
865
- )
866
- return ChainInvocationOutcome(
867
- served_command=None,
868
- returncode=last_usage_limited.returncode,
869
- stdout=last_usage_limited.stdout,
870
- stderr=last_usage_limited.stderr,
871
- attempts=tuple(all_attempts),
872
- terminal_status=TERMINAL_STATUS_CHAIN_EXHAUSTED,
873
- session_id=None,
874
- )
875
-
876
-
877
- def _classify_completion(
878
- entry: ChainEntry,
879
- completion: subprocess.CompletedProcess[str],
880
- all_attempts: list[ChainAttempt],
881
- *,
882
- routing_mode: str,
883
- ) -> ChainInvocationOutcome | None:
884
- if completion.returncode == 0:
885
- all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_SERVED))
886
- return _served_outcome(entry.command, completion, all_attempts)
887
- if _is_usage_limit_failure(completion):
888
- all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_USAGE_LIMITED))
889
- return None
890
- all_attempts.append(ChainAttempt(entry.command, ATTEMPT_STATUS_NONZERO_EXIT))
891
- if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT:
892
- return _advisor_blocked_outcome(completion, all_attempts)
893
- return _served_outcome(entry.command, completion, all_attempts)
894
-
895
-
896
- def _ranked_entries_or_config_order(
897
- all_entries: list[ChainEntry],
898
- config_path: Path,
899
- ) -> list[ChainEntry]:
900
- try:
901
- all_usage_reports = chain_weekly_usage_reporter(config_path=config_path)
902
- return _entries_ranked_by_weekly_remaining(
903
- all_entries, all_usage_reports
904
- )
905
- except (ImportError, AttributeError):
906
- return list(all_entries)
907
-
908
-
909
- def extract_resume_session_id(
910
- all_claude_arguments: Sequence[str],
911
- ) -> str | None:
912
- """Return the session id after ``--resume`` when present.
913
-
914
- Args:
915
- all_claude_arguments: Arguments passed after the binary name.
916
-
917
- Returns:
918
- The non-empty session id, or ``None`` when the flag is absent.
919
- """
920
- for each_index, each_argument in enumerate(all_claude_arguments):
921
- if each_argument != RESUME_SESSION_FLAG:
922
- continue
923
- next_index = each_index + 1
924
- if next_index >= len(all_claude_arguments):
925
- return None
926
- session_id = all_claude_arguments[next_index]
927
- if session_id and not session_id.startswith("-"):
928
- return session_id
929
- return None
930
- return None
931
-
932
-
933
- def lookup_affinity_command(
934
- store: AffinityStore,
935
- session_id: str,
936
- ) -> str | None:
937
- """Return the bound binary command for ``session_id``, if any.
938
-
939
- Args:
940
- store: Loaded affinity store.
941
- session_id: Session id from a prior serve.
942
-
943
- Returns:
944
- The bound command string, or ``None`` when unbound.
945
- """
946
- for each_binding in store.all_bindings:
947
- if each_binding.session_id == session_id:
948
- return each_binding.command
949
- return None
950
-
951
-
952
- def order_entries_for_resume(
953
- all_entries: list[ChainEntry],
954
- *,
955
- preferred_command: str | None,
956
- ) -> list[ChainEntry]:
957
- """Put the preferred originating binary first; keep relative order of the rest.
958
-
959
- When ``preferred_command`` is missing from the chain or is ``None``, the
960
- original list order is returned unchanged (documented fallback).
961
-
962
- Args:
963
- all_entries: Chain entries in the mode's base order.
964
- preferred_command: Originating binary from the affinity store.
965
-
966
- Returns:
967
- Ordered walk list for this invocation.
968
- """
969
- if preferred_command is None:
970
- return list(all_entries)
971
- preferred: list[ChainEntry] = []
972
- remaining: list[ChainEntry] = []
973
- for each_entry in all_entries:
974
- if each_entry.command == preferred_command and not preferred:
975
- preferred.append(each_entry)
976
- else:
977
- remaining.append(each_entry)
978
- if not preferred:
979
- return list(all_entries)
980
- return preferred + remaining
981
-
982
-
983
- def _resolve_walk_entries(
984
- all_entries: list[ChainEntry],
985
- config_path: Path,
986
- routing_mode: str,
987
- ) -> list[ChainEntry]:
988
- if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT:
989
- return list(all_entries)
990
- return _ranked_entries_or_config_order(all_entries, config_path)
991
-
992
-
993
- def _affinity_preferred_command(
994
- all_claude_arguments: Sequence[str],
995
- affinity_path: Path,
996
- ) -> str | None:
997
- resume_session_id = extract_resume_session_id(all_claude_arguments)
998
- if resume_session_id is None:
999
- return None
1000
- try:
1001
- affinity_store = load_affinity_store(affinity_path)
1002
- except ValueError:
1003
- return None
1004
- return lookup_affinity_command(affinity_store, resume_session_id)
1005
-
1006
-
1007
- def _walk_entries_for_invocation(
1008
- all_entries: list[ChainEntry],
1009
- config_path: Path,
1010
- routing_mode: str,
1011
- all_claude_arguments: Sequence[str],
1012
- affinity_path: Path,
1013
- ) -> list[ChainEntry]:
1014
- base_order = _resolve_walk_entries(all_entries, config_path, routing_mode)
1015
- preferred_command = _affinity_preferred_command(
1016
- all_claude_arguments, affinity_path
1017
- )
1018
- return order_entries_for_resume(
1019
- base_order, preferred_command=preferred_command
1020
- )
1021
-
1022
-
1023
- def _require_known_routing_mode(routing_mode: str) -> str:
1024
- if routing_mode not in ALL_ROUTING_MODES:
1025
- raise ValueError(
1026
- f"Unknown routing_mode {routing_mode!r}; "
1027
- f"expected one of {sorted(ALL_ROUTING_MODES)}"
1028
- )
1029
- return routing_mode
1030
-
1031
-
1032
- def run_claude(
1033
- all_claude_arguments: list[str],
1034
- *,
1035
- timeout_seconds: int,
1036
- stdin_text: str | None = None,
1037
- routing_mode: str = DEFAULT_ROUTING_MODE,
1038
- ) -> ChainInvocationOutcome:
1039
- """Run *all_claude_arguments* through the fallback chain.
1040
-
1041
- ::
1042
-
1043
- usage_ranked (default): highest remaining first
1044
- ordered_account: config order; usage-limit-only fallover
1045
- ordered_account + auth/timeout/generic process error
1046
- -> terminal_status=advisor_blocked (no fallover)
1047
- zero exit with JSON session_id
1048
- -> outcome.session_id set for later --resume
1049
-
1050
- Default mode probes weekly remaining once, ranks highest first, then walks
1051
- that order. Ordered-account mode walks config order and never probes usage.
1052
- Only a usage-limit failure falls over. Missing binaries are skipped and the
1053
- walk continues; timeout and other nonzero exits stop. In ordered-account
1054
- mode those non-usage stops report ``advisor_blocked``. When usage ranking
1055
- infrastructure fails to load under usage-ranked mode, the walk uses config
1056
- order instead.
1057
-
1058
- Args:
1059
- all_claude_arguments: Arguments passed after the binary name, such as
1060
- ``["-p", prompt, "--strict-mcp-config"]``.
1061
- timeout_seconds: Timeout applied to each binary invocation.
1062
- stdin_text: Optional UTF-8 text forwarded as stdin to every binary.
1063
- ``None`` leaves the subprocess without a piped stdin body.
1064
- routing_mode: ``usage_ranked`` (default) or ``ordered_account``.
1065
-
1066
- Returns:
1067
- The outcome of the walk, naming the serving binary, terminal status,
1068
- optional session id, and the full attempt trail.
1069
-
1070
- Raises:
1071
- ChainConfigurationError: When the chain configuration cannot be loaded.
1072
- ValueError: When *routing_mode* is not a known mode.
1073
- """
1074
- selected_routing_mode = _require_known_routing_mode(routing_mode)
1075
- config_path = chain_config_path()
1076
- all_entries = load_chain(config_path)
1077
- affinity_path = default_affinity_state_path(
1078
- Path.home() / CLAUDE_HOME_SUBDIRECTORY
1079
- )
1080
- all_walk_entries = _walk_entries_for_invocation(
1081
- all_entries,
1082
- config_path,
1083
- selected_routing_mode,
1084
- all_claude_arguments,
1085
- affinity_path,
1086
- )
1087
- return _walk_chain_attempts(
1088
- all_walk_entries,
1089
- all_claude_arguments=all_claude_arguments,
1090
- timeout_seconds=timeout_seconds,
1091
- stdin_text=stdin_text,
1092
- routing_mode=selected_routing_mode,
1093
- affinity_path=affinity_path,
1094
- )
1095
-
1096
-
1097
- def _walk_chain_attempts(
1098
- all_walk_entries: list[ChainEntry],
1099
- *,
1100
- all_claude_arguments: list[str],
1101
- timeout_seconds: int,
1102
- stdin_text: str | None,
1103
- routing_mode: str,
1104
- affinity_path: Path,
1105
- ) -> ChainInvocationOutcome:
1106
- all_attempts: list[ChainAttempt] = []
1107
- last_usage_limited: subprocess.CompletedProcess[str] | None = None
1108
- for each_entry in all_walk_entries:
1109
- try:
1110
- completion = chain_subprocess_runner(
1111
- _build_invocation(each_entry, all_claude_arguments),
1112
- capture_output=True,
1113
- text=True,
1114
- encoding=UTF8_ENCODING,
1115
- errors=CODEC_ERROR_STRATEGY,
1116
- timeout=timeout_seconds,
1117
- check=False,
1118
- input=stdin_text,
1119
- )
1120
- except subprocess.TimeoutExpired as timeout_error:
1121
- all_attempts.append(
1122
- ChainAttempt(each_entry.command, ATTEMPT_STATUS_TIMEOUT)
1123
- )
1124
- timeout_terminal_status = (
1125
- TERMINAL_STATUS_ADVISOR_BLOCKED
1126
- if routing_mode == ROUTING_MODE_ORDERED_ACCOUNT
1127
- else TERMINAL_STATUS_TIMEOUT
1128
- )
1129
- return _no_process_outcome(
1130
- all_attempts,
1131
- timeout_error,
1132
- terminal_status=timeout_terminal_status,
1133
- )
1134
- except FileNotFoundError:
1135
- all_attempts.append(
1136
- ChainAttempt(each_entry.command, ATTEMPT_STATUS_EXECUTABLE_NOT_FOUND)
1137
- )
1138
- continue
1139
- terminal_outcome = _classify_completion(
1140
- each_entry,
1141
- completion,
1142
- all_attempts,
1143
- routing_mode=routing_mode,
1144
- )
1145
- if terminal_outcome is not None:
1146
- _persist_served_affinity(
1147
- affinity_path,
1148
- served_command=each_entry.command,
1149
- session_id=terminal_outcome.session_id,
1150
- )
1151
- return terminal_outcome
1152
- last_usage_limited = completion
1153
- return _exhausted_outcome(all_attempts, last_usage_limited)
1154
-
1155
-
1156
- def _persist_served_affinity(
1157
- affinity_path: Path,
1158
- *,
1159
- served_command: str,
1160
- session_id: str | None,
1161
- ) -> None:
1162
- """Best-effort write of session-to-binary affinity after a successful serve."""
1163
- if not session_id:
1164
- return
1165
- try:
1166
- store = load_affinity_store(affinity_path)
1167
- updated = record_affinity_binding(
1168
- store, session_id=session_id, command=served_command
1169
- )
1170
- save_affinity_store_atomic(affinity_path, updated)
1171
- except (OSError, ValueError):
1172
- return
1173
-
1174
-
1175
- def _run_cli_chain(
1176
- all_claude_arguments: list[str],
1177
- *,
1178
- timeout_seconds: int,
1179
- stdin_text: str | None,
1180
- routing_mode: str,
1181
- ) -> ChainInvocationOutcome:
1182
- """Run the CLI-selected chain arguments through the public runner."""
1183
- return run_claude(
1184
- all_claude_arguments,
1185
- timeout_seconds=timeout_seconds,
1186
- stdin_text=stdin_text,
1187
- routing_mode=routing_mode,
1188
- )
1189
-
1190
-
1191
- def _build_argument_parser() -> argparse.ArgumentParser:
1192
- parser = argparse.ArgumentParser(
1193
- description="Run a claude invocation through the fallback chain."
1194
- )
1195
- parser.add_argument(
1196
- CLI_TIMEOUT_FLAG,
1197
- dest="timeout_seconds",
1198
- type=int,
1199
- default=DEFAULT_TIMEOUT_SECONDS,
1200
- help="Timeout in seconds applied to each binary invocation.",
1201
- )
1202
- parser.add_argument(
1203
- CLI_ROUTING_MODE_FLAG,
1204
- dest="routing_mode",
1205
- choices=sorted(ALL_ROUTING_MODES),
1206
- default=DEFAULT_ROUTING_MODE,
1207
- help=(
1208
- "Chain routing: usage_ranked (default) or ordered_account "
1209
- "(config order, usage-limit-only fallover)."
1210
- ),
1211
- )
1212
- parser.add_argument("passthrough", nargs=argparse.REMAINDER)
1213
- return parser
1214
-
1215
-
1216
- def _strip_leading_separator(all_passthrough: list[str]) -> list[str]:
1217
- if all_passthrough and all_passthrough[0] == CLI_ARGUMENTS_SEPARATOR:
1218
- return all_passthrough[1:]
1219
- return all_passthrough
1220
-
1221
-
1222
- def _exhausted_message(all_attempts: tuple[ChainAttempt, ...]) -> str:
1223
- attempt_summary = ATTEMPT_SUMMARY_JOIN_SEPARATOR.join(
1224
- ATTEMPT_SUMMARY_ENTRY_TEMPLATE.format(
1225
- command=each_attempt.command, status=each_attempt.status
1226
- )
1227
- for each_attempt in all_attempts
1228
- )
1229
- return CHAIN_EXHAUSTED_MESSAGE_TEMPLATE.format(attempt_summary=attempt_summary)
1230
-
1231
-
1232
- def _read_piped_stdin_text() -> str | None:
1233
- if sys.stdin.isatty():
1234
- return None
1235
- return sys.stdin.read()
1236
-
1237
-
1238
- def main(all_command_arguments: list[str]) -> int:
1239
- """Walk the chain for CLI arguments and return the process exit code.
1240
-
1241
- ::
1242
-
1243
- main(["--", "-p", "hi"])
1244
- main(["--routing-mode", "ordered_account", "--", "-p", "hi"])
1245
-
1246
- Args:
1247
- all_command_arguments: The argument vector after the program name.
1248
-
1249
- Returns:
1250
- The served binary's return code, a distinct code when the chain is
1251
- exhausted or advisor-blocked, or a distinct code when the configuration
1252
- cannot be loaded.
1253
- """
1254
- parser = _build_argument_parser()
1255
- parsed_arguments = parser.parse_args(all_command_arguments)
1256
- all_claude_arguments = _strip_leading_separator(parsed_arguments.passthrough)
1257
- maybe_stdin_text = _read_piped_stdin_text()
1258
- try:
1259
- chain_outcome = _run_cli_chain(
1260
- all_claude_arguments,
1261
- timeout_seconds=parsed_arguments.timeout_seconds,
1262
- stdin_text=maybe_stdin_text,
1263
- routing_mode=parsed_arguments.routing_mode,
1264
- )
1265
- except ChainConfigurationError as configuration_error:
1266
- print(str(configuration_error), file=sys.stderr)
1267
- return CHAIN_CONFIG_ERROR_EXIT_CODE
1268
- if chain_outcome.terminal_status == TERMINAL_STATUS_ADVISOR_BLOCKED:
1269
- sys.stdout.write(chain_outcome.stdout)
1270
- sys.stderr.write(chain_outcome.stderr)
1271
- return CHAIN_ADVISOR_BLOCKED_EXIT_CODE
1272
- if chain_outcome.served_command is None:
1273
- print(_exhausted_message(chain_outcome.attempts), file=sys.stderr)
1274
- return CHAIN_EXHAUSTED_EXIT_CODE
1275
- sys.stdout.write(chain_outcome.stdout)
1276
- sys.stderr.write(chain_outcome.stderr)
1277
- return chain_outcome.returncode
1278
-
1279
-
1280
- def _reconfigure_stream_to_utf8(stream: TextIO) -> None:
1281
- """Reconfigure *stream* to emit UTF-8, replacing any unmappable character."""
1282
- if isinstance(stream, io.TextIOWrapper):
1283
- stream.reconfigure(encoding=UTF8_ENCODING, errors=CODEC_ERROR_STRATEGY)
1284
-
1285
-
1286
- if __name__ == "__main__":
1287
- _reconfigure_stream_to_utf8(sys.stdout)
1288
- _reconfigure_stream_to_utf8(sys.stderr)
1289
- sys.exit(main(sys.argv[1:]))