aicp-cli 0.3.0__py3-none-any.whl

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.
aicp/runner.py ADDED
@@ -0,0 +1,560 @@
1
+ """Run one headless prompt through the fallback chain of AI CLIs.
2
+
3
+ Port of ``_ai_run``/``_aicp_invoke``/``_aicp_timeout`` in ``~/scripts/bin/aicp``.
4
+
5
+ Returns a :class:`StepResult` naming the first CLI that exits 0 plus every CLI
6
+ that reported a supported quota signal during the step.
7
+
8
+ **Signal handling is the load-bearing part.** The zsh original wraps every
9
+ invocation in GNU ``timeout --foreground``, and ``--foreground`` is not
10
+ cosmetic: without it ``timeout`` puts the CLI in a NEW process group, the
11
+ terminal delivers Ctrl+C only to its FOREGROUND group, so SIGINT never reached
12
+ the CLI, the CLI never exited, and the script stayed blocked on a child it had
13
+ no way to stop — Ctrl+C looked dead. :func:`_invoke` keeps the child in this
14
+ process's own process group (POSIX) for exactly that reason. The documented
15
+ cost, inherited deliberately, is that a timeout then signals only the CLI
16
+ itself and not its grandchildren; an interruptible Ctrl+C is worth more than
17
+ reaping those.
18
+
19
+ The three outcome rules that follow from it:
20
+
21
+ * a step killed by a signal returns :data:`ABORT_RC` and NEVER falls through to
22
+ the next CLI — 128+N means a user abort or a shutdown, not a reason to retry;
23
+ * a CLI exiting above 128 is treated the same way (``abort``);
24
+ * a CLI killed by its own budget DOES fall through — that one is aicp's doing,
25
+ not the user's.
26
+
27
+ This module deliberately imports no sibling feature module (config, notify,
28
+ gitflow, secrets, skills): the fallback chain arrives as a plain
29
+ ``Sequence[str]`` parameter and the notifier as a :data:`~aicp.contracts.NotifyFn`
30
+ parameter, so the runner is testable with no config machinery and no network.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import os
37
+ import shutil
38
+ import signal
39
+ import subprocess
40
+ import sys
41
+ import tempfile
42
+ import threading
43
+ import time
44
+ from collections.abc import Generator, Sequence
45
+ from dataclasses import dataclass
46
+ from pathlib import Path
47
+ from typing import Any, TextIO
48
+
49
+ from aicp import budget as budget_mod
50
+ from aicp import quota, timing
51
+ from aicp._utils import (
52
+ BOLD,
53
+ CYAN,
54
+ DIM,
55
+ GREEN,
56
+ IS_WINDOWS,
57
+ RED,
58
+ RESET,
59
+ YELLOW,
60
+ have,
61
+ run_interruptible,
62
+ )
63
+ from aicp.contracts import NotifyFn
64
+ from aicp.i18n import t
65
+ from aicp.present import Spinner
66
+
67
+ __all__ = [
68
+ "ABORT_RC",
69
+ "KILL_AFTER",
70
+ "TIMEOUT_RC",
71
+ "StepResult",
72
+ "classify",
73
+ "invoke_argv",
74
+ "normalize_rc",
75
+ "run_step",
76
+ ]
77
+
78
+ TIMEOUT_RC = 124 # GNU timeout's own "the command timed out" status
79
+ ABORT_RC = 130 # 128 + SIGINT
80
+ KILL_AFTER = 10 # grace period between SIGTERM and SIGKILL (timeout --kill-after=10)
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class StepResult:
85
+ """One fallback step's caller-facing result.
86
+
87
+ ``quota_clis`` records exhausted handlers even when a later CLI wins. The
88
+ caller owns how long those exclusions live; :func:`aicp.cli._flow` keeps
89
+ them only for its commit/push pair.
90
+ """
91
+
92
+ rc: int
93
+ winner: str | None = None
94
+ quota_clis: tuple[str, ...] = ()
95
+
96
+ # Above this, a budget is treated as "no timeout" rather than a deadline. A
97
+ # budget is only ever a digit string out of the environment or .aicprc, so it
98
+ # can be arbitrarily large, and Python cannot wait that long: a 309-digit value
99
+ # has no float form at all (OverflowError converting the timeout), and even a
100
+ # representable one above ~292 years is rejected by the selector the wait runs
101
+ # on (TypeError) — both of them AFTER the child is already spawned. A year is
102
+ # indistinguishable from "never" for one CLI invocation, and this keeps
103
+ # budget.py's stated invariant true: a bad config value never bricks aicp.
104
+ _MAX_DEADLINE = 365 * 24 * 3600
105
+
106
+ # Per-CLI flags, ported flag-for-flag from _aicp_invoke. Each branch skips MCP
107
+ # server startup where the CLI exposes a way to — /commit and /safe-git-push
108
+ # are plain git prompts, no MCP tool use — which measurably shortens cold start;
109
+ # codex and agy showed no consistent gain, so they keep their existing flags.
110
+ _FLAGS: dict[str, tuple[str, ...]] = {
111
+ # agy resumes its last session/workspace by default, ignoring $PWD entirely
112
+ # unless --new-project pins it to the caller's cwd.
113
+ "agy": ("--dangerously-skip-permissions", "--new-project"),
114
+ "claude": ("--dangerously-skip-permissions", "--strict-mcp-config"),
115
+ "copilot": ("--allow-all", "--disable-builtin-mcps", "--no-auto-update"),
116
+ "grok": ("--always-approve", "--no-subagents"),
117
+ # --trust is not the approval flag (--auto-approve is) and is not optional:
118
+ # vibe asks once, interactively, before it will touch an untrusted
119
+ # directory, and its own --help names --trust as the answer for
120
+ # non-interactive automation. Without it the very first aicp run in a new
121
+ # repo blocks on a prompt nobody can see behind the spinner until the budget
122
+ # kills it — a hang presented as a timeout. Per-invocation, never written to
123
+ # vibe's trusted_folders.toml, so aicp leaves no persistent trust behind.
124
+ "vibe": ("--auto-approve", "--trust"),
125
+ }
126
+
127
+ # Literal signals confirmed by T1. Copilot and agy are intentionally absent:
128
+ # neither had a supported quota signature, and broad guesses would turn an
129
+ # ordinary failure into a run-scoped exclusion.
130
+ _QUOTA_SIGNALS: dict[str, tuple[str, ...]] = {
131
+ "codex": ("usage_limit_reached", "The usage limit has been reached"),
132
+ "claude": ('"type":"rate_limit_error"', "Usage limit reached"),
133
+ "vibe": (
134
+ "Rate limit exceeded. Please wait a moment before trying again.",
135
+ "Rate limits exceeded. Please wait a moment before trying again.",
136
+ ),
137
+ "grok": (
138
+ "You've hit the rate limit for your plan.",
139
+ "You've reached your free Grok Build usage limit for now.",
140
+ "API rate limit. Ask a team admin to purchase more credits for higher limits",
141
+ ),
142
+ }
143
+
144
+
145
+ def _noop(_message: str) -> None:
146
+ """Default notifier: say nothing. T4 injects the real one at the call site."""
147
+
148
+
149
+ def invoke_argv(cli: str, prompt: str) -> list[str] | None:
150
+ """The exact argv for *cli*, or None if this name has no invocation.
151
+
152
+ An unknown name yields None rather than an empty command: the zsh `case`
153
+ silently returned 0 for a name it did not know, which would be read here as
154
+ "that CLI succeeded" without anything having run.
155
+ """
156
+ if cli == "codex": # the only one that takes the prompt positionally
157
+ return ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", prompt]
158
+ flags = _FLAGS.get(cli)
159
+ return None if flags is None else [cli, "-p", prompt, *flags]
160
+
161
+
162
+ # ── Windows: launching an npm shim ───────────────────────────────────────────
163
+ #
164
+ # `invoke_argv` builds argv[0] as the BARE binary name, which is all POSIX
165
+ # needs. On Windows it was unlaunchable. CreateProcess (what subprocess uses
166
+ # when shell=False) does not consult %PATHEXT%: given "copilot" it looks for
167
+ # "copilot.exe" and nothing else, and it cannot execute a batch file even when
168
+ # handed one, because a .cmd is a script that only cmd.exe can interpret.
169
+ #
170
+ # Every one of aicp's six CLIs is npm-installed, and npm's Windows shim is a
171
+ # .cmd — so `Popen(["copilot", ...])` raised `FileNotFoundError: [WinError 2]`
172
+ # on every Windows machine. `have()` could not catch it: shutil.which DOES
173
+ # honor %PATHEXT%, so the gate said the CLI was present and the very next line
174
+ # failed to start it. aicp on Windows could therefore never run a single step;
175
+ # a user would have seen every CLI in the chain die instantly and the run end
176
+ # with "✗ no AI CLI could run this step" despite the CLIs being installed.
177
+ #
178
+ # The fix resolves the binary once, so its real extension is known, and hands a
179
+ # batch shim to the interpreter that can run it. Deliberately NOT shell=True:
180
+ # that would build one string out of the whole command and hand cmd.exe a
181
+ # shell-injection surface the project bans. Here the interpreter is named
182
+ # explicitly, argv stays a list, and the arguments are individually quoted.
183
+ _BATCH_SUFFIXES = frozenset({".cmd", ".bat"})
184
+
185
+ # cmd.exe re-parses whatever command line it is given, and quoting each argument
186
+ # neutralizes every metacharacter except these: `"` ends the quoting, and `%`
187
+ # is expanded inside quotes too. There is no sound, portable escape for either
188
+ # one on a cmd command line, so a launch carrying them is refused rather than
189
+ # half-escaped. aicp never builds one — prompts are slash-command names and the
190
+ # flags are fixed literals in _FLAGS — so this is a tripwire, not a limit
191
+ # anyone meets.
192
+ # ponytail: fail closed; write a real cmd quoter only if aicp ever has to pass
193
+ # arbitrary free text as a prompt on Windows.
194
+ _CMD_UNSAFE = frozenset('"%\r\n\x00')
195
+
196
+
197
+ def _cmd_quote(part: str) -> str:
198
+ """Wrap *part* in the quotes cmd.exe needs, keeping the closing one a
199
+ delimiter.
200
+
201
+ A trailing run of backslashes has to be doubled first. cmd.exe hands the
202
+ line to a batch shim, the shim hands it to the binary it wraps (node, for
203
+ every npm-installed CLI), and THAT binary splits its own argv on the
204
+ Microsoft C-runtime rule, where ``\\"`` is a literal quote rather than a
205
+ delimiter. An odd-length trailing run would therefore escape the closing
206
+ quote and let one argument swallow the next — a boundary desync two layers
207
+ below the one :data:`_CMD_UNSAFE` screens. ``subprocess.list2cmdline``
208
+ doubles them for exactly this reason; so does this.
209
+ """
210
+ trailing = len(part) - len(part.rstrip("\\"))
211
+ return '"' + part + "\\" * trailing + '"'
212
+
213
+
214
+ def _launch_command(argv: list[str]) -> list[str] | str:
215
+ """*argv* rewritten so this platform can actually start it.
216
+
217
+ Returns *argv* unchanged off Windows. On Windows, returns the resolved
218
+ executable (so CreateProcess does no PATH search of its own), or — for a
219
+ .cmd/.bat shim — a full ``cmd.exe /d /s /c "..."`` command line as a single
220
+ string, which is how the quoting below survives being handed on verbatim.
221
+ """
222
+ if not IS_WINDOWS:
223
+ return argv
224
+
225
+ resolved = shutil.which(argv[0])
226
+ if resolved is None: # pragma: no cover - run_step gates on have() first
227
+ return argv
228
+ if os.path.splitext(resolved)[1].lower() not in _BATCH_SUFFIXES:
229
+ return [resolved, *argv[1:]]
230
+
231
+ parts = [resolved, *argv[1:]]
232
+ bad = sorted({c for part in parts for c in part if c in _CMD_UNSAFE})
233
+ if bad:
234
+ raise ValueError(
235
+ f"refusing to run {argv[0]} through cmd.exe: an argument contains "
236
+ f"{''.join(bad)!r}, which cmd.exe would interpret rather than pass on"
237
+ )
238
+ # %ComSpec% is how Windows itself names the interpreter; the literal path is
239
+ # only a floor for a stripped environment. /d skips the registry AutoRun
240
+ # command, so a machine-wide AutoRun cannot inject itself into every aicp
241
+ # step. /s makes cmd's quote handling the ONE predictable rule — strip the
242
+ # outer pair, take the rest verbatim — instead of the five-condition dance
243
+ # it otherwise does, which mangles the line as soon as two arguments happen
244
+ # to be quoted (an install path with a space plus a prompt with a space).
245
+ # Joined with a literal separator rather than os.path.join: this branch is
246
+ # a Windows path being built, and os.path.join would spell it with forward
247
+ # slashes on the machine most of this suite's platform-faking runs on.
248
+ system_root = os.environ.get("SystemRoot") or "C:\\Windows"
249
+ comspec = os.environ.get("ComSpec") or f"{system_root}\\System32\\cmd.exe"
250
+ inner = " ".join(_cmd_quote(part) for part in parts)
251
+ return f'"{comspec}" /d /s /c "{inner}"'
252
+
253
+
254
+ def normalize_rc(returncode: int) -> int:
255
+ """Python reports a signal-killed child as ``-N``; zsh reports ``128+N``."""
256
+ return 128 - returncode if returncode < 0 else returncode
257
+
258
+
259
+ def classify(cli: str, rc: int, *, timed_out: bool, output: str) -> str:
260
+ """The timing-log bucket, in abort / ok / timeout / quota / fail order.
261
+
262
+ ``timed_out`` is the fact of having killed the child, not a guess from its
263
+ status — so a CLI that exits 124 on its own is a plain ``fail``, where the
264
+ zsh original (which could only compare against GNU timeout's exit code)
265
+ had to call it a timeout.
266
+ """
267
+ if rc > 128:
268
+ return "abort"
269
+ if rc == 0:
270
+ return "ok"
271
+ if timed_out:
272
+ return "timeout"
273
+ if any(signal in output for signal in _QUOTA_SIGNALS.get(cli, ())):
274
+ return "quota"
275
+ return "fail"
276
+
277
+
278
+ def _stream_capture(path: str, done: threading.Event) -> None:
279
+ """Follow a private capture file without changing the child's stdout fd."""
280
+ try:
281
+ with open(path, encoding="utf-8", errors="replace") as reader:
282
+ while True:
283
+ chunk = reader.read()
284
+ if chunk:
285
+ sys.stdout.write(chunk)
286
+ sys.stdout.flush()
287
+ continue
288
+ if done.wait(0.02):
289
+ chunk = reader.read()
290
+ if chunk:
291
+ sys.stdout.write(chunk)
292
+ sys.stdout.flush()
293
+ return
294
+ except (OSError, ValueError):
295
+ return
296
+
297
+
298
+ def _invoke(
299
+ argv: list[str],
300
+ seconds: int,
301
+ *,
302
+ cwd: Path | None,
303
+ capture: TextIO | None,
304
+ ) -> tuple[int, bool]:
305
+ """Run *argv* under a *seconds* budget; returns ``(rc, timed_out)``.
306
+
307
+ ``seconds <= 0`` means no timeout at all (GNU timeout reads 0 the same
308
+ way), and takes the bare :func:`~aicp._utils.run_interruptible` path; so
309
+ does a budget above :data:`_MAX_DEADLINE`, for the reason stated there.
310
+
311
+ The timed path cannot compose with ``run_interruptible``: enforcing a
312
+ deadline needs the ``Popen`` handle (to SIGTERM, then SIGKILL, the child),
313
+ and ``run_interruptible`` owns its own and exposes no timeout hook. The
314
+ process-group posture below is therefore kept deliberately identical to
315
+ it — same group on POSIX, ``CREATE_NEW_PROCESS_GROUP`` plus forwarded
316
+ ``CTRL_BREAK_EVENT`` on Windows — and a change to either must be made in
317
+ both places.
318
+
319
+ On Windows a batch-shim CLI runs under a cmd.exe this function spawned (see
320
+ :func:`_launch_command`), which widens the limitation this module's
321
+ docstring already states: ``terminate()``/``kill()`` then reach cmd.exe and
322
+ not the CLI behind it. ``CREATE_NEW_PROCESS_GROUP`` puts both in the same
323
+ new group, so the ``CTRL_BREAK_EVENT`` path — the one that matters for
324
+ Ctrl+C — still reaches the CLI itself.
325
+ """
326
+ command = _launch_command(argv)
327
+ kwargs: dict[str, Any] = {"cwd": cwd}
328
+ if IS_WINDOWS:
329
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
330
+ if capture is not None:
331
+ kwargs["stdout"] = capture
332
+ kwargs["stderr"] = subprocess.STDOUT
333
+
334
+ if seconds <= 0 or seconds > _MAX_DEADLINE:
335
+ return normalize_rc(run_interruptible(command, **kwargs).returncode), False
336
+
337
+ # `with Popen(...)`, plus a kill on the way out of any exception this
338
+ # function does not otherwise handle — exactly what subprocess.run does,
339
+ # and for the same reason: between spawn and wait, ANY escaping exception
340
+ # (not just the two named below) would otherwise leave a live child with
341
+ # nobody left to reap it.
342
+ with subprocess.Popen(command, **kwargs) as proc: # type: ignore[arg-type]
343
+ try:
344
+ proc.communicate(timeout=seconds)
345
+ except subprocess.TimeoutExpired:
346
+ # SIGTERM first, SIGKILL after the grace period — timeout's
347
+ # --kill-after=10. Only the CLI itself is signalled, not its
348
+ # grandchildren; see this module's docstring.
349
+ proc.terminate()
350
+ try:
351
+ proc.communicate(timeout=KILL_AFTER)
352
+ except subprocess.TimeoutExpired:
353
+ proc.kill()
354
+ proc.communicate()
355
+ return TIMEOUT_RC, True
356
+ except KeyboardInterrupt:
357
+ if IS_WINDOWS:
358
+ # No foreground-process-group concept: the console's Ctrl+C did
359
+ # not reach the child, so forward it (CTRL_BREAK_EVENT, the only
360
+ # one Win32 can target at a specific group).
361
+ try:
362
+ proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined]
363
+ except Exception: # noqa: BLE001 - delivery can fail many ways
364
+ proc.terminate()
365
+ # On POSIX the child already took the same SIGINT (same process
366
+ # group); wait for it to unwind rather than second-guessing it.
367
+ proc.communicate()
368
+ raise
369
+ except BaseException:
370
+ proc.kill()
371
+ raise
372
+ return normalize_rc(proc.returncode), False
373
+
374
+
375
+ @contextlib.contextmanager
376
+ def _capture_file() -> Generator[tuple[TextIO, str], None, None]:
377
+ """One reusable handle and path for the child's combined output.
378
+
379
+ ``mkstemp`` rather than a name built from the pid. ``aicp.<pid>.log`` is
380
+ guessable, and ``open(path, "w")`` follows whatever is already sitting at
381
+ that name — so on any box whose temp dir is shared (``/tmp`` on Linux and
382
+ in CI; the sticky bit stops someone deleting another user's file, never
383
+ creating a new name of their own first) a planted symlink got this user's
384
+ CLI output written over a file of the attacker's choosing, at mode 0644.
385
+ ``mkstemp`` opens ``O_EXCL`` at mode 0600: unguessable, unfollowable, and
386
+ unreadable by anyone else.
387
+
388
+ The handle is opened once per run and rewound between CLIs rather than
389
+ reopened by path, so the name is never resolved a second time.
390
+ """
391
+ fd, name = tempfile.mkstemp(prefix="aicp.", suffix=".log")
392
+ try:
393
+ # "w+" so _replay can read back what the child wrote through the
394
+ # inherited fd; errors="replace" because a CLI's output is arbitrary
395
+ # bytes and a decode error here must never break the run.
396
+ with os.fdopen(fd, "w+", encoding="utf-8", errors="replace") as handle:
397
+ yield handle, name
398
+ finally:
399
+ with contextlib.suppress(OSError):
400
+ os.unlink(name)
401
+
402
+
403
+ def _notify_quietly(notify: NotifyFn, message: str) -> None:
404
+ """Send *message*, swallowing any failure — see the call site's comment."""
405
+ try:
406
+ notify(message)
407
+ except Exception: # noqa: BLE001 - an injected notifier's failure modes are not knowable here
408
+ return
409
+
410
+
411
+ def _replay(capture: TextIO, out: TextIO) -> None:
412
+ """Echo the last 5 lines of a failed CLI's captured output."""
413
+ try:
414
+ capture.seek(0)
415
+ tail = capture.read().splitlines()[-5:]
416
+ except (OSError, ValueError):
417
+ return
418
+ for line in tail:
419
+ print(f" {DIM}{line}{RESET}", file=out)
420
+
421
+
422
+ def _captured_text(capture: TextIO) -> str:
423
+ try:
424
+ capture.seek(0)
425
+ return capture.read()
426
+ except (AttributeError, OSError, ValueError):
427
+ return ""
428
+
429
+
430
+ def run_step(
431
+ prompt: str,
432
+ chain: Sequence[str],
433
+ *,
434
+ cwd: Path | None = None,
435
+ notify: NotifyFn = _noop,
436
+ verbose: bool = False,
437
+ stream: TextIO | None = None,
438
+ ) -> StepResult:
439
+ """Run *prompt* through *chain*, one styled line per CLI.
440
+
441
+ The budget is computed fresh per CLI, inside the loop: each CLI widens its
442
+ own budget from its own history, so the printed hint has to be recomputed
443
+ right before whichever CLI is about to run.
444
+ """
445
+ out = stream if stream is not None else sys.stdout
446
+ quota_clis: list[str] = []
447
+ with _capture_file() as (capture, capture_path):
448
+ for cli in chain:
449
+ argv = invoke_argv(cli, prompt)
450
+ if argv is None or not have(cli):
451
+ continue
452
+
453
+ # An earlier run already found this one out of quota. Skipping is
454
+ # the whole point: the alternative is burning a full budget on a
455
+ # CLI that cannot succeed until its window expires.
456
+ remaining = quota.cooling(cli)
457
+ if remaining:
458
+ note = t(
459
+ "step_quota_cooling",
460
+ "quota cooldown — %sm left, skipping",
461
+ -(-remaining // 60), # round up, so it never reads "0m left"
462
+ )
463
+ print(f" {YELLOW}⚠{RESET} {CYAN}{cli}{RESET}{DIM} {note}{RESET}", file=out)
464
+ continue
465
+
466
+ allowed = budget_mod.compute(cli, cwd=cwd)
467
+ hint = t("budget_hint", "budget %ss · %s", allowed.seconds, allowed.note)
468
+ print(f"{BOLD}▸ {prompt}{RESET} {DIM}{hint}{RESET}", file=out)
469
+
470
+ started = time.monotonic()
471
+ spinner = Spinner(f"{cli} · {prompt}")
472
+ stream_done = threading.Event()
473
+ stream_thread = (
474
+ threading.Thread(
475
+ target=_stream_capture, args=(capture_path, stream_done), daemon=True
476
+ )
477
+ if verbose
478
+ else None
479
+ )
480
+ try:
481
+ if not verbose:
482
+ spinner.start()
483
+ capture.seek(0) # this CLI's output only, not the last one's
484
+ capture.truncate()
485
+ if stream_thread is not None:
486
+ stream_thread.start()
487
+ rc, timed_out = _invoke(argv, allowed.seconds, cwd=cwd, capture=capture)
488
+ except KeyboardInterrupt:
489
+ timing.append(cli, prompt, int(time.monotonic() - started), "abort", ABORT_RC)
490
+ print(
491
+ f"{YELLOW}⚠{RESET} "
492
+ + t("interrupted", "interrupted (Ctrl+C) — aborting, no further steps run"),
493
+ file=out,
494
+ )
495
+ return StepResult(ABORT_RC, quota_clis=tuple(quota_clis))
496
+ finally:
497
+ stream_done.set()
498
+ if stream_thread is not None:
499
+ stream_thread.join()
500
+ spinner.stop()
501
+
502
+ elapsed = int(time.monotonic() - started)
503
+ output = _captured_text(capture)
504
+ outcome = classify(cli, rc, timed_out=timed_out, output=output)
505
+ timing.append(cli, prompt, elapsed, outcome, rc)
506
+
507
+ if outcome == "abort":
508
+ note = t("step_signal_note", "signal %s", rc - 128)
509
+ print(f" {YELLOW}⚠{RESET} {CYAN}{cli}{RESET}{DIM} {elapsed}s · {note}{RESET}", file=out)
510
+ return StepResult(ABORT_RC, quota_clis=tuple(quota_clis))
511
+
512
+ if outcome == "ok":
513
+ print(f" {GREEN}✓{RESET} {CYAN}{cli}{RESET}{DIM} {elapsed}s{RESET}", file=out)
514
+ return StepResult(0, winner=cli, quota_clis=tuple(quota_clis))
515
+
516
+ if outcome == "timeout":
517
+ note = t("step_timeout_note", "timed out (> %ss)", allowed.seconds)
518
+ print(f" {YELLOW}⏱{RESET} {CYAN}{cli}{RESET}{DIM} {elapsed}s · {note}{RESET}", file=out)
519
+ # A notifier that fails must never abort a run that would
520
+ # otherwise commit and push: the zsh `_aicp_notify` returns 1
521
+ # at worst (it prints "(telegram unavailable)" and carries on)
522
+ # and `_ai_run` ignores that status. T4 owns the injected
523
+ # implementation, so its failure mode is not knowable here.
524
+ _notify_quietly(
525
+ notify,
526
+ t(
527
+ "tg_timeout",
528
+ '⏱️ aicp: %s timed out running "%s" on %s (over the %ss budget, %s)'
529
+ " — falling through to the next CLI.",
530
+ Path(cwd or os.getcwd()).name,
531
+ prompt,
532
+ cli,
533
+ allowed.seconds,
534
+ allowed.note,
535
+ )
536
+ )
537
+ elif outcome == "quota":
538
+ quota_clis.append(cli)
539
+ quota.record(cli)
540
+ note = t("step_quota_note", "quota/rate-limit exhausted — skipping for this run")
541
+ print(f" {YELLOW}⚠{RESET} {CYAN}{cli}{RESET}{DIM} {elapsed}s · {note}{RESET}", file=out)
542
+ _notify_quietly(
543
+ notify,
544
+ t(
545
+ "tg_quota",
546
+ '⚠️ aicp: %s exhausted quota/rate limit running "%s" on %s'
547
+ " — skipping it for the rest of this run.",
548
+ Path(cwd or os.getcwd()).name,
549
+ prompt,
550
+ cli,
551
+ ),
552
+ )
553
+ else:
554
+ note = t("step_exit_note", "exit %s", rc)
555
+ print(f" {RED}✗{RESET} {CYAN}{cli}{RESET}{DIM} {elapsed}s · {note}{RESET}", file=out)
556
+
557
+ _replay(capture, out)
558
+
559
+ print(f"{RED}" + t("no_cli_for_step", " ✗ no AI CLI could run this step") + RESET, file=out)
560
+ return StepResult(1, quota_clis=tuple(quota_clis))