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/__init__.py +18 -0
- aicp/_keyreader.py +210 -0
- aicp/_skills_data/commit.md +90 -0
- aicp/_skills_data/safe-git-push/SKILL.md +55 -0
- aicp/_skills_data/safe-git-push/scripts/safe_push.py +248 -0
- aicp/_utils.py +183 -0
- aicp/budget.py +158 -0
- aicp/cli.py +595 -0
- aicp/config.py +434 -0
- aicp/contracts.py +144 -0
- aicp/gitflow.py +456 -0
- aicp/i18n.py +228 -0
- aicp/menu.py +1013 -0
- aicp/notify.py +87 -0
- aicp/present.py +303 -0
- aicp/quota.py +115 -0
- aicp/runner.py +560 -0
- aicp/secrets.py +291 -0
- aicp/skills.py +679 -0
- aicp/timing.py +116 -0
- aicp_cli-0.3.0.dist-info/METADATA +298 -0
- aicp_cli-0.3.0.dist-info/RECORD +25 -0
- aicp_cli-0.3.0.dist-info/WHEEL +4 -0
- aicp_cli-0.3.0.dist-info/entry_points.txt +2 -0
- aicp_cli-0.3.0.dist-info/licenses/LICENSE +20 -0
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))
|