dev-autopilot 0.4.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.
- autopilot/__init__.py +4 -0
- autopilot/__main__.py +6 -0
- autopilot/backends/__init__.py +159 -0
- autopilot/backends/claude_cli.py +368 -0
- autopilot/backends/command.py +90 -0
- autopilot/cli.py +1614 -0
- autopilot/commands/__init__.py +0 -0
- autopilot/commands/preflight.py +131 -0
- autopilot/config.py +155 -0
- autopilot/context.py +414 -0
- autopilot/dash_term.py +493 -0
- autopilot/dash_web.py +347 -0
- autopilot/dashboard.py +410 -0
- autopilot/decisions.py +85 -0
- autopilot/deploy.py +67 -0
- autopilot/docs.py +301 -0
- autopilot/doctor.py +478 -0
- autopilot/gate.py +383 -0
- autopilot/github_util.py +42 -0
- autopilot/gitops.py +251 -0
- autopilot/growth.py +85 -0
- autopilot/handoff.py +27 -0
- autopilot/layers.py +134 -0
- autopilot/log_scrub.py +11 -0
- autopilot/mcp.py +104 -0
- autopilot/notify.py +98 -0
- autopilot/orchestrator.py +2214 -0
- autopilot/plain.py +238 -0
- autopilot/plan.py +320 -0
- autopilot/proc.py +176 -0
- autopilot/prompts/auditor.md +39 -0
- autopilot/prompts/decide.md +38 -0
- autopilot/prompts/docs.md +48 -0
- autopilot/prompts/fixer.md +26 -0
- autopilot/prompts/onboard.md +43 -0
- autopilot/prompts/plan_writer.md +40 -0
- autopilot/prompts/replanner.md +42 -0
- autopilot/prompts/research.md +25 -0
- autopilot/prompts/resume.md +10 -0
- autopilot/prompts/retry.md +8 -0
- autopilot/prompts/review.md +40 -0
- autopilot/prompts/system.md +16 -0
- autopilot/prompts/task.md +78 -0
- autopilot/prompts/triage.md +30 -0
- autopilot/prompts/unstick.md +37 -0
- autopilot/prompts/verify.md +32 -0
- autopilot/repomap.py +71 -0
- autopilot/report.py +633 -0
- autopilot/rights.py +184 -0
- autopilot/schemas.py +42 -0
- autopilot/state.py +298 -0
- autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
- autopilot/templates/agent/BRAIN.md +21 -0
- autopilot/templates/agent/CLAUDE.md.snippet +10 -0
- autopilot/templates/agent/DECISIONS.md +5 -0
- autopilot/templates/agent/HANDOFF.md +3 -0
- autopilot/templates/agent/gitignore +10 -0
- autopilot/templates/agent/ide_rules.md +8 -0
- autopilot/templates/agent/plan.yaml +48 -0
- autopilot/templates/agent/project.yaml +259 -0
- autopilot/templates/cloudflare_apply.py +211 -0
- autopilot/templates/deploy.sh +72 -0
- autopilot/templates/email_verify.py +144 -0
- autopilot/templates/infra/cloudflare.yaml +11 -0
- autopilot/templates/presets/docker.yaml +4 -0
- autopilot/templates/presets/generic.yaml +3 -0
- autopilot/templates/presets/go.yaml +6 -0
- autopilot/templates/presets/java.yaml +4 -0
- autopilot/templates/presets/node.yaml +7 -0
- autopilot/templates/presets/python.yaml +6 -0
- autopilot/templates/presets/rust.yaml +5 -0
- autopilot/templates/presets/static.yaml +3 -0
- autopilot/upgrade.py +192 -0
- dev_autopilot-0.4.0.dist-info/METADATA +148 -0
- dev_autopilot-0.4.0.dist-info/RECORD +79 -0
- dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
- dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
- dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
- dev_autopilot-0.4.0.dist-info/top_level.txt +1 -0
autopilot/__init__.py
ADDED
autopilot/__main__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Agent backends. Each runs ONE fresh session and returns a SessionResult."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
# Anchored to a line start: the CLI prints these as its own message, so prose inside a failed session doesn't count.
|
|
10
|
+
LIMIT_RX = re.compile(r"^\s*(?:you've|you have) hit your (?:[\w-]+ ){0,3}limit"
|
|
11
|
+
r"(?:\s*[·•|\-–—,.]*\s*resets?\s+(?P<reset>[^\n]+))?", re.I | re.M)
|
|
12
|
+
API_LIMIT_RX = re.compile(r"^\s*API Error: (?:429|529)\b|^\s*API Error:.*(?:rate_limit_error|overloaded_error)", re.I | re.M)
|
|
13
|
+
_RESET_RX = re.compile(r"^(?:(?P<mon>[a-z]{3,9})\.?\s+(?P<day>\d{1,2})\s*(?:,|at)?\s*)?(?P<h>\d{1,2})(?::(?P<m>\d{2}))?"
|
|
14
|
+
r"\s*(?P<ap>am|pm)?\s*(?:\((?P<tz>[^)]+)\))?$", re.I)
|
|
15
|
+
_MONTHS = {m: i + 1 for i, m in enumerate("jan feb mar apr may jun jul aug sep oct nov dec".split())}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_reset(s: str, now: datetime) -> float | None:
|
|
19
|
+
"""Next future occurrence of a CLI reset time like `3:45pm`, `Oct 3, 9am (Asia/Calcutta)`; None if unparseable."""
|
|
20
|
+
try:
|
|
21
|
+
m = _RESET_RX.match((s or "").strip().rstrip(".").strip())
|
|
22
|
+
if not m or not (m["ap"] or m["m"]):
|
|
23
|
+
return None
|
|
24
|
+
tz = now.tzinfo
|
|
25
|
+
if m["tz"]:
|
|
26
|
+
try:
|
|
27
|
+
from zoneinfo import ZoneInfo
|
|
28
|
+
tz = ZoneInfo(m["tz"].strip())
|
|
29
|
+
except Exception: # noqa: BLE001 no tzdata / unknown zone: fall back to local time
|
|
30
|
+
pass
|
|
31
|
+
if now.tzinfo:
|
|
32
|
+
now = now.astimezone(tz)
|
|
33
|
+
h, mi = int(m["h"]), int(m["m"] or 0)
|
|
34
|
+
if m["ap"]:
|
|
35
|
+
if not 1 <= h <= 12:
|
|
36
|
+
return None
|
|
37
|
+
h = h % 12 + (12 if m["ap"].lower() == "pm" else 0)
|
|
38
|
+
if m["mon"]:
|
|
39
|
+
mon = _MONTHS.get(m["mon"][:3].lower())
|
|
40
|
+
if not mon:
|
|
41
|
+
return None
|
|
42
|
+
t = now.replace(month=mon, day=int(m["day"]), hour=h, minute=mi, second=0, microsecond=0)
|
|
43
|
+
if t <= now:
|
|
44
|
+
t = t.replace(year=t.year + 1)
|
|
45
|
+
else:
|
|
46
|
+
t = now.replace(hour=h, minute=mi, second=0, microsecond=0)
|
|
47
|
+
if t <= now:
|
|
48
|
+
t += timedelta(days=1)
|
|
49
|
+
return t.timestamp()
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_limit(text: str, now: datetime | None = None) -> tuple[bool, float | None]:
|
|
55
|
+
"""(limited, reset_at epoch). Only the CLI's subscription message or API 429/529 errors count, never agent prose."""
|
|
56
|
+
text = text or ""
|
|
57
|
+
m = LIMIT_RX.search(text)
|
|
58
|
+
if m:
|
|
59
|
+
return True, (parse_reset(m["reset"], now or datetime.now().astimezone()) if m["reset"] else None)
|
|
60
|
+
return bool(API_LIMIT_RX.search(text)), None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Failures of the machinery, not of the work: retried by the orchestrator, never counted as a task attempt.
|
|
64
|
+
# Anchored to a line start like LIMIT_RX, so the agent's own prose about these words never counts.
|
|
65
|
+
_AUTH_RX = re.compile(r"^\s*(?:Invalid API key|Please run /login|OAuth token (?:has )?expired|API Error: 401\b)",
|
|
66
|
+
re.I | re.M)
|
|
67
|
+
# Node's own error shapes ("reason: getaddrinfo ENOTFOUND", "Error: connect ECONNREFUSED"), not a bare code in prose
|
|
68
|
+
_NET_RX = re.compile(r"^\s*API Error: (?:Connection error|Request timed out|50[0234]\b)|"
|
|
69
|
+
r"(?:reason:|Error:)\s+(?:getaddrinfo |connect |read |write )?"
|
|
70
|
+
r"(?:ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN)\b", re.I | re.M)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def detect_infra(text: str) -> str:
|
|
74
|
+
"""'auth' (not logged in / key rejected), 'network' (connection, timeout, server 5xx) or ''."""
|
|
75
|
+
text = text or ""
|
|
76
|
+
return "auth" if _AUTH_RX.search(text) else "network" if _NET_RX.search(text) else ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class SessionRequest:
|
|
81
|
+
prompt: str
|
|
82
|
+
model: str
|
|
83
|
+
cwd: str
|
|
84
|
+
timeout_sec: int = 3600
|
|
85
|
+
budget_usd: float | None = None
|
|
86
|
+
system_append: str = ""
|
|
87
|
+
read_only: bool = False
|
|
88
|
+
log_path: str = ""
|
|
89
|
+
resume: str = "" # continue this CLI session instead of starting fresh
|
|
90
|
+
resume_totals: dict = field(default_factory=dict) # that session's totals so far (see SessionResult.totals)
|
|
91
|
+
schema: dict | None = None # JSON Schema of the final report (schemas.REPORTS)
|
|
92
|
+
effort: str = "" # overrides agent.effort for this session
|
|
93
|
+
web_only: bool = False # research: only web search/fetch and reading files
|
|
94
|
+
mcp_config: str = "" # MCP servers for this session only (e.g. Playwright)
|
|
95
|
+
no_tools: bool = False # triage of untrusted text: no file, shell, web or MCP tools
|
|
96
|
+
label: str = "" # live progress lines: "P01-T01 Project skeleton [haiku]"
|
|
97
|
+
on_context: object = None # J5: called with the session's context size (tokens)
|
|
98
|
+
on_tokens: object = None # G8: called with the session's running token total
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class SessionResult:
|
|
103
|
+
ok: bool
|
|
104
|
+
text: str = ""
|
|
105
|
+
cost: float = 0.0
|
|
106
|
+
session_id: str = ""
|
|
107
|
+
report: dict = field(default_factory=dict)
|
|
108
|
+
error: str = ""
|
|
109
|
+
rate_limited: bool = False
|
|
110
|
+
reset_at: float | None = None
|
|
111
|
+
timed_out: bool = False
|
|
112
|
+
stuck: bool = False # killed for repeating the same action (stream-json watch)
|
|
113
|
+
usage: dict = field(default_factory=dict) # model -> {input, output, cache_read, cache_write, cost}
|
|
114
|
+
num_turns: int = 0
|
|
115
|
+
duration_ms: int = 0
|
|
116
|
+
totals: dict = field(default_factory=dict) # whole-session {cost, usage, num_turns, duration_ms}, for a later resume
|
|
117
|
+
infra: str = "" # 'cli' | 'auth' | 'network': the machinery failed, not the work
|
|
118
|
+
window: dict = field(default_factory=dict) # G1: the CLI's own usage figure {five_hour: {pct, reset}, seven_day, status}
|
|
119
|
+
config: dict = field(default_factory=dict) # I1: what the session loaded from the user's Claude config (names, counts)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def since(totals: dict, base: dict) -> dict:
|
|
123
|
+
"""A resumed CLI session reports whole-session totals; keep only what this run added (never below zero)."""
|
|
124
|
+
def sub(a, b):
|
|
125
|
+
return max(0, (a or 0) - (b or 0))
|
|
126
|
+
bu = base.get("usage") or {}
|
|
127
|
+
usage = {m: {k: sub(v, (bu.get(m) or {}).get(k)) for k, v in u.items()} for m, u in (totals.get("usage") or {}).items()}
|
|
128
|
+
return {"cost": sub(totals.get("cost"), base.get("cost")), "usage": usage,
|
|
129
|
+
"num_turns": sub(totals.get("num_turns"), base.get("num_turns")),
|
|
130
|
+
"duration_ms": sub(totals.get("duration_ms"), base.get("duration_ms"))}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_report(text: str) -> dict:
|
|
134
|
+
"""Extract the last ```json block (or bare JSON object) from the agent's final message."""
|
|
135
|
+
blocks = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text or "", re.S)
|
|
136
|
+
candidates = list(reversed(blocks))
|
|
137
|
+
if not candidates:
|
|
138
|
+
m = re.search(r"(\{[^{}]*\"status\".*\})\s*$", text or "", re.S)
|
|
139
|
+
if m:
|
|
140
|
+
candidates = [m.group(1)]
|
|
141
|
+
for c in candidates:
|
|
142
|
+
try:
|
|
143
|
+
data = json.loads(c)
|
|
144
|
+
if isinstance(data, dict):
|
|
145
|
+
return data
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
continue
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_backend(cfg):
|
|
152
|
+
kind = cfg.get("agent.backend", "claude_cli")
|
|
153
|
+
if kind == "claude_cli":
|
|
154
|
+
from .claude_cli import ClaudeCLIBackend
|
|
155
|
+
return ClaudeCLIBackend(cfg)
|
|
156
|
+
if kind == "command":
|
|
157
|
+
from .command import CommandBackend
|
|
158
|
+
return CommandBackend(cfg)
|
|
159
|
+
raise ValueError(f"unknown backend {kind}")
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Claude Code headless backend: `claude -p --output-format stream-json`, one session per call (fresh or resumed)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..proc import agent_env, stream_proc
|
|
13
|
+
from . import SessionRequest, SessionResult, detect_infra, detect_limit, parse_report, since
|
|
14
|
+
|
|
15
|
+
progress = logging.getLogger("autopilot")
|
|
16
|
+
READ_ONLY_DENY = ["Edit", "Write", "NotebookEdit"]
|
|
17
|
+
# Deny-list, not --tools: --tools might also drop the synthetic tool that --json-schema relies on.
|
|
18
|
+
WEB_ONLY_DENY = ["Bash", "Agent", "Task"]
|
|
19
|
+
# Untrusted issue text: nothing to read (it could copy secrets into a committed task), run or fetch.
|
|
20
|
+
NO_TOOLS_DENY = READ_ONLY_DENY + WEB_ONLY_DENY + [
|
|
21
|
+
"Read", "Glob", "Grep", "LS", "WebSearch", "WebFetch", "NotebookRead", "TodoWrite", "MultiEdit", "BashOutput",
|
|
22
|
+
"KillShell", "Skill", "SlashCommand", "ExitPlanMode", "ListMcpResourcesTool", "ReadMcpResourceTool"]
|
|
23
|
+
WEB_TOOLS = ["WebFetch", "WebSearch"]
|
|
24
|
+
FILE_READ_TOOLS = ["Read", "Glob", "Grep", "LS", "NotebookRead"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _num(v, kind=int):
|
|
28
|
+
try:
|
|
29
|
+
return kind(v or 0)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
return kind(0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_usage(data: dict, model: str) -> dict:
|
|
35
|
+
"""model -> token counts and cost from the CLI's JSON result; {} when absent or odd."""
|
|
36
|
+
try:
|
|
37
|
+
mu = data.get("modelUsage")
|
|
38
|
+
if isinstance(mu, dict) and mu:
|
|
39
|
+
return {str(m): {"input": _num(u.get("inputTokens")), "output": _num(u.get("outputTokens")),
|
|
40
|
+
"cache_read": _num(u.get("cacheReadInputTokens")),
|
|
41
|
+
"cache_write": _num(u.get("cacheCreationInputTokens")), "cost": _num(u.get("costUSD"), float)}
|
|
42
|
+
for m, u in mu.items() if isinstance(u, dict)}
|
|
43
|
+
u = data.get("usage")
|
|
44
|
+
if isinstance(u, dict):
|
|
45
|
+
return {model: {"input": _num(u.get("input_tokens")), "output": _num(u.get("output_tokens")),
|
|
46
|
+
"cache_read": _num(u.get("cache_read_input_tokens")),
|
|
47
|
+
"cache_write": _num(u.get("cache_creation_input_tokens")),
|
|
48
|
+
"cost": _num(data.get("total_cost_usd"), float)}}
|
|
49
|
+
except Exception: # noqa: BLE001 odd shapes never break a run
|
|
50
|
+
pass
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ponytail: list prices per MTok (input, output) on 2026-10-01; cache reads 0.1x and cache writes 1.25x input.
|
|
55
|
+
# Only used to estimate killed sessions (no result event); update when prices change.
|
|
56
|
+
PRICES = {"opus": (4.0, 20.0), "sonnet": (2.0, 10.0), "haiku": (1.0, 5.0)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def partial_usage(raw: str, model: str) -> dict:
|
|
60
|
+
"""model -> tokens and estimated cost of a session killed before its `result` event (stuck, timeout), from the
|
|
61
|
+
assistant messages' Messages-API `usage`. Undocumented in stream-json, so missing fields count as 0. A message
|
|
62
|
+
split over several events repeats its usage: the last one per message id counts."""
|
|
63
|
+
last = {}
|
|
64
|
+
for i, ev in enumerate(_events(raw)):
|
|
65
|
+
msg = ev.get("message") if ev.get("type") == "assistant" else None
|
|
66
|
+
if isinstance(msg, dict) and isinstance(msg.get("usage"), dict):
|
|
67
|
+
last[msg.get("id") or i] = (str(msg.get("model") or model), msg["usage"])
|
|
68
|
+
out: dict = {}
|
|
69
|
+
for m, u in last.values():
|
|
70
|
+
r = out.setdefault(m, {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "cost": 0.0})
|
|
71
|
+
for k, src in (("input", "input_tokens"), ("output", "output_tokens"),
|
|
72
|
+
("cache_read", "cache_read_input_tokens"), ("cache_write", "cache_creation_input_tokens")):
|
|
73
|
+
r[k] += _num(u.get(src))
|
|
74
|
+
for m, r in out.items(): # unknown model: priced as opus, so the window budget errs on the safe side
|
|
75
|
+
pin, pout = next((p for k, p in PRICES.items() if k in m.lower()), PRICES["opus"])
|
|
76
|
+
r["cost"] = round((r["input"] * pin + r["output"] * pout + r["cache_read"] * pin * 0.1
|
|
77
|
+
+ r["cache_write"] * pin * 1.25) / 1e6, 6)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def usage_meter():
|
|
82
|
+
"""G8: a running token total for one stream, fed line by line (the fields `partial_usage` reads). A message
|
|
83
|
+
split over several events repeats its usage, so the last one per message id counts. `feed.context` is the size
|
|
84
|
+
of the newest message of the main agent: how full the session's context is now (J5)."""
|
|
85
|
+
last: dict = {}
|
|
86
|
+
|
|
87
|
+
def feed(line: str) -> int:
|
|
88
|
+
try:
|
|
89
|
+
ev = json.loads(line)
|
|
90
|
+
except ValueError:
|
|
91
|
+
ev = None
|
|
92
|
+
msg = ev.get("message") if isinstance(ev, dict) and ev.get("type") == "assistant" else None
|
|
93
|
+
if isinstance(msg, dict) and isinstance(msg.get("usage"), dict):
|
|
94
|
+
last[msg.get("id") or len(last)] = sum(_num(msg["usage"].get(k)) for k in (
|
|
95
|
+
"input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"))
|
|
96
|
+
if not ev.get("parent_tool_use_id"): # a helper agent has a context of its own
|
|
97
|
+
feed.context = last[msg.get("id") or len(last) - 1]
|
|
98
|
+
return sum(last.values())
|
|
99
|
+
feed.context = 0
|
|
100
|
+
return feed
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _events(raw: str):
|
|
104
|
+
for line in raw.splitlines():
|
|
105
|
+
try:
|
|
106
|
+
ev = json.loads(line)
|
|
107
|
+
except ValueError:
|
|
108
|
+
continue
|
|
109
|
+
if isinstance(ev, dict):
|
|
110
|
+
yield ev
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def result_event(raw: str) -> dict:
|
|
114
|
+
"""The final `result` event of a stream-json run (or a plain JSON result); {} when there is none."""
|
|
115
|
+
for ev in reversed(list(_events(raw))):
|
|
116
|
+
if ev.get("type", "result") == "result":
|
|
117
|
+
return ev
|
|
118
|
+
try:
|
|
119
|
+
data = json.loads(raw)
|
|
120
|
+
return data if isinstance(data, dict) else {}
|
|
121
|
+
except ValueError:
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def window_info(raw: str) -> dict:
|
|
126
|
+
"""G1: the usage window as the CLI reports it in the stream's last `rate_limit_event`:
|
|
127
|
+
{five_hour: {pct, reset}, seven_day: {pct, reset}, status}; pct is 0..1, reset is unix seconds. The field names
|
|
128
|
+
are observed, not documented, so anything missing or odd gives {} and the caller keeps its own estimate."""
|
|
129
|
+
info = next((ev.get("rate_limit_info") for ev in reversed(list(_events(raw)))
|
|
130
|
+
if ev.get("type") == "rate_limit_event"), None)
|
|
131
|
+
wins = info.get("unifiedWindows") if isinstance(info, dict) else None
|
|
132
|
+
out = {}
|
|
133
|
+
for name in ("five_hour", "seven_day"):
|
|
134
|
+
w = wins.get(name) if isinstance(wins, dict) else None
|
|
135
|
+
pct, reset = (w.get("utilization"), w.get("resetsAt")) if isinstance(w, dict) else (None, None)
|
|
136
|
+
real = all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in (pct, reset))
|
|
137
|
+
if real and 0 <= pct <= 1 and 0 < reset < 4e9: # another scale, NaN, a broken clock: not trusted
|
|
138
|
+
out[name] = {"pct": float(pct), "reset": float(reset)}
|
|
139
|
+
if out:
|
|
140
|
+
out["status"] = str(info.get("status") or "")
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def config_load(raw: str) -> dict:
|
|
145
|
+
"""I1: what the session loaded from the user's own Claude config, from the first `init` event and the SessionStart
|
|
146
|
+
hook events: {plugins: [names], mcp: {connected, other}, mcp_names: [names], hooks: n}. Names and counts only (never
|
|
147
|
+
paths, versions or settings). {} when the CLI says nothing about it (an older CLI)."""
|
|
148
|
+
evs = list(_events(raw))
|
|
149
|
+
init = next((e for e in evs if e.get("type") == "system" and e.get("subtype") == "init"), None)
|
|
150
|
+
if not init or not any(k in init for k in ("plugins", "mcp_servers")):
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
def named(key):
|
|
154
|
+
items = init.get(key)
|
|
155
|
+
return [x for x in items if isinstance(x, dict)] if isinstance(items, list) else []
|
|
156
|
+
mcp = named("mcp_servers")
|
|
157
|
+
connected = sum(1 for m in mcp if m.get("status") == "connected")
|
|
158
|
+
return {"plugins": [str(p.get("name")) for p in named("plugins") if p.get("name")],
|
|
159
|
+
"mcp": {"connected": connected, "other": len(mcp) - connected},
|
|
160
|
+
"mcp_names": [str(m.get("name")) for m in mcp if m.get("name")],
|
|
161
|
+
"hooks": sum(1 for e in evs if e.get("subtype") == "hook_started" and e.get("hook_event") == "SessionStart")}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def first_session_id(raw: str) -> str:
|
|
165
|
+
return next((str(ev["session_id"]) for ev in _events(raw) if ev.get("session_id")), "")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def activity(line: str) -> str:
|
|
169
|
+
"""What a stream-json line shows the session doing: Claude's short text, or one entry per tool call such as
|
|
170
|
+
`Edit src/app/cli.py`. The live sign that a session is working ('' for anything else)."""
|
|
171
|
+
if '"assistant"' not in line:
|
|
172
|
+
return ""
|
|
173
|
+
ev = next(_events(line), {})
|
|
174
|
+
content = (ev.get("message") or {}).get("content") if ev.get("type") == "assistant" else None
|
|
175
|
+
out = []
|
|
176
|
+
for c in content if isinstance(content, list) else []:
|
|
177
|
+
if isinstance(c, dict) and c.get("type") == "text" and str(c.get("text") or "").strip():
|
|
178
|
+
text = " ".join(str(c["text"]).split()) # Claude's own short status line, as Claude Code shows it
|
|
179
|
+
out.append(f'"{text[:110]}{"…" if len(text) > 110 else ""}"')
|
|
180
|
+
elif isinstance(c, dict) and c.get("type") == "tool_use":
|
|
181
|
+
inp = c.get("input") if isinstance(c.get("input"), dict) else {}
|
|
182
|
+
what = next((inp[k] for k in ("file_path", "command", "pattern", "url", "query", "description")
|
|
183
|
+
if inp.get(k)), "")
|
|
184
|
+
out.append(f"{c.get('name')} {' '.join(str(what).split())[:100]}".rstrip())
|
|
185
|
+
return "; ".join(out)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def stuck_watch(limit: int):
|
|
189
|
+
"""on_line hook: a reason once the agent makes the same tool call `limit` times in a row (0 = never)."""
|
|
190
|
+
last, count = [None], [0]
|
|
191
|
+
|
|
192
|
+
def watch(line: str) -> str:
|
|
193
|
+
if not limit or '"tool_use"' not in line:
|
|
194
|
+
return ""
|
|
195
|
+
ev = next(_events(line), {})
|
|
196
|
+
content = (ev.get("message") or {}).get("content") if ev.get("type") == "assistant" else None
|
|
197
|
+
for c in content if isinstance(content, list) else []:
|
|
198
|
+
if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("name") != "StructuredOutput":
|
|
199
|
+
# J14.1: the final report is never "stuck"; a repeated one is ignored (the result event carries it once)
|
|
200
|
+
key = json.dumps([c.get("name"), c.get("input")], sort_keys=True, default=str)
|
|
201
|
+
count[0] = count[0] + 1 if key == last[0] else 1
|
|
202
|
+
last[0] = key
|
|
203
|
+
if count[0] >= limit:
|
|
204
|
+
return (f"stuck: the agent repeated the same action {limit} times in a row: "
|
|
205
|
+
f"{c.get('name')} {json.dumps(c.get('input'), default=str)[:300]}")
|
|
206
|
+
return ""
|
|
207
|
+
return watch
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def sandbox_settings(cfg) -> dict:
|
|
211
|
+
"""P5 egress allowlist on Claude Code's own sandbox: shell commands reach only `sandbox.allowed_domains`, with
|
|
212
|
+
no retry outside the sandbox and no silent fallback when bubblewrap/socat are missing."""
|
|
213
|
+
return {"sandbox": {"enabled": True, "failIfUnavailable": True, "allowUnsandboxedCommands": False,
|
|
214
|
+
"network": {"allowedDomains": [str(d) for d in cfg.get("sandbox.allowed_domains", [])]}}}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ClaudeCLIBackend:
|
|
218
|
+
supports_resume = True
|
|
219
|
+
|
|
220
|
+
def __init__(self, cfg):
|
|
221
|
+
self.cfg = cfg
|
|
222
|
+
self.binary = self.resolve_binary()
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def resolve_binary() -> str:
|
|
226
|
+
found = os.environ.get("AUTOPILOT_CLAUDE_BIN") or os.environ.get("AUTODEV_CLAUDE_BIN") or shutil.which("claude")
|
|
227
|
+
if not found: # H3: the native installer's folder; a terminal opened before the install has no PATH entry for it
|
|
228
|
+
native = Path.home() / ".local" / "bin" / ("claude.exe" if os.name == "nt" else "claude")
|
|
229
|
+
found = str(native) if native.is_file() else None
|
|
230
|
+
if found and found.lower().endswith((".cmd", ".bat")): # npm's Windows shim only calls this exe: skip cmd.exe
|
|
231
|
+
exe = Path(found).parent / "node_modules" / "@anthropic-ai" / "claude-code" / "bin" / "claude.exe"
|
|
232
|
+
if exe.is_file():
|
|
233
|
+
return str(exe)
|
|
234
|
+
return found or "claude"
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def shim(self) -> bool:
|
|
238
|
+
"""Windows .cmd/.bat shims go through cmd.exe, which mangles multi-line argv; the system text rides on stdin."""
|
|
239
|
+
return self.binary.lower().endswith((".cmd", ".bat"))
|
|
240
|
+
|
|
241
|
+
def env(self) -> dict:
|
|
242
|
+
"""agent_env plus the Claude Code knobs Autopilot sets: subagent model (U5), prompt cache TTL (U7)."""
|
|
243
|
+
env = agent_env(self.cfg)
|
|
244
|
+
for key, name in (("models.subagent", "CLAUDE_CODE_SUBAGENT_MODEL"),
|
|
245
|
+
("agent.cache_ttl", "CLAUDE_CODE_PROMPT_CACHE_TTL")):
|
|
246
|
+
if self.cfg.get(key):
|
|
247
|
+
env[name] = str(self.cfg.get(key))
|
|
248
|
+
if not self.cfg.get("agent.user_config"): # I2: no claude.ai connectors; --setting-sources can't drop them
|
|
249
|
+
env.setdefault("ENABLE_CLAUDEAI_MCP_SERVERS", "false")
|
|
250
|
+
return env
|
|
251
|
+
|
|
252
|
+
def build_cmd(self, req: SessionRequest) -> list[str]:
|
|
253
|
+
c = self.cfg
|
|
254
|
+
cmd = [self.binary, "-p", "--output-format", "stream-json", "--verbose", "--model", req.model,
|
|
255
|
+
"--permission-mode", c.get("agent.permission_mode", "bypassPermissions")]
|
|
256
|
+
if req.resume:
|
|
257
|
+
cmd += ["--resume", req.resume]
|
|
258
|
+
if req.mcp_config: # only these servers, not the project's own .mcp.json
|
|
259
|
+
cmd += ["--mcp-config", req.mcp_config, "--strict-mcp-config"]
|
|
260
|
+
elif req.no_tools or c.get("sandbox.enabled"): # no MCP servers at all (they'd run outside the sandbox)
|
|
261
|
+
cmd += ["--strict-mcp-config"]
|
|
262
|
+
if int(c.get("agent.max_turns", 0) or 0):
|
|
263
|
+
cmd += ["--max-turns", str(int(c.get("agent.max_turns")))]
|
|
264
|
+
if req.budget_usd:
|
|
265
|
+
cmd += ["--max-budget-usd", f"{req.budget_usd:.2f}"]
|
|
266
|
+
if c.get("models.fallback"):
|
|
267
|
+
cmd += ["--fallback-model", c.get("models.fallback")]
|
|
268
|
+
if req.effort or c.get("agent.effort"):
|
|
269
|
+
cmd += ["--effort", req.effort or c.get("agent.effort")]
|
|
270
|
+
if req.system_append and not self.shim:
|
|
271
|
+
cmd += ["--append-system-prompt", req.system_append]
|
|
272
|
+
if req.schema and not self.shim: # quotes through cmd.exe are fragile; the report is then parsed from text
|
|
273
|
+
cmd += ["--json-schema", json.dumps(req.schema, separators=(",", ":"))]
|
|
274
|
+
allowed = c.get("agent.allowed_tools", [])
|
|
275
|
+
if allowed:
|
|
276
|
+
cmd += ["--allowedTools", *allowed]
|
|
277
|
+
denied = list(c.get("agent.disallowed_tools", []))
|
|
278
|
+
if req.read_only:
|
|
279
|
+
denied += READ_ONLY_DENY
|
|
280
|
+
if req.web_only: # research reads untrusted pages: no shell, no subagents (removed even under bypass)
|
|
281
|
+
denied += WEB_ONLY_DENY
|
|
282
|
+
if req.no_tools:
|
|
283
|
+
denied += NO_TOOLS_DENY
|
|
284
|
+
if c.get("sandbox.enabled"):
|
|
285
|
+
cmd += ["--settings", json.dumps(sandbox_settings(c), separators=(",", ":"))]
|
|
286
|
+
# The sandbox covers shell commands only, so a session gets the web or the repo, never both.
|
|
287
|
+
denied += FILE_READ_TOOLS if req.web_only else WEB_TOOLS
|
|
288
|
+
if denied:
|
|
289
|
+
cmd += ["--disallowedTools", *denied]
|
|
290
|
+
extra = list(c.get("agent.extra_args", []))
|
|
291
|
+
if not c.get("agent.user_config") and not any(str(a).split("=")[0] == "--setting-sources" for a in extra):
|
|
292
|
+
# I2: no user settings, plugins, hooks or MCP servers, so a run acts the same on every PC (smoke plan:
|
|
293
|
+
# ~27k fewer context tokens and ~40 s less per session). The project's own settings and .mcp.json, the
|
|
294
|
+
# --settings flag (sandbox) and the user's CLAUDE.md (not a setting) still load.
|
|
295
|
+
cmd += ["--setting-sources", "project,local"]
|
|
296
|
+
cmd += extra
|
|
297
|
+
return cmd
|
|
298
|
+
|
|
299
|
+
def run(self, req: SessionRequest) -> SessionResult:
|
|
300
|
+
cmd = self.build_cmd(req)
|
|
301
|
+
prompt = f"{req.system_append}\n\n---\n\n{req.prompt}" if req.system_append and self.shim else req.prompt
|
|
302
|
+
watch = stuck_watch(int(self.cfg.get("agent.stuck_repeats", 4) or 0))
|
|
303
|
+
started = time.monotonic()
|
|
304
|
+
log = None
|
|
305
|
+
if req.log_path: # written live: `tail -f` shows what a running session does
|
|
306
|
+
Path(req.log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
log = open(req.log_path, "w", encoding="utf-8") # noqa: SIM115
|
|
308
|
+
log.write(f"$ {' '.join(cmd[:10])} ...\n\nSTDOUT:\n")
|
|
309
|
+
|
|
310
|
+
meter, told = usage_meter(), [float("-inf")]
|
|
311
|
+
|
|
312
|
+
def on_line(line: str) -> str:
|
|
313
|
+
if log:
|
|
314
|
+
log.write(line)
|
|
315
|
+
log.flush()
|
|
316
|
+
if req.on_tokens: # G8: the status line counts this session's tokens while it runs (every 5 s at most)
|
|
317
|
+
tokens = meter(line)
|
|
318
|
+
if tokens and time.monotonic() - told[0] >= 5:
|
|
319
|
+
told[0] = time.monotonic()
|
|
320
|
+
if req.on_context:
|
|
321
|
+
req.on_context(meter.context)
|
|
322
|
+
req.on_tokens(tokens)
|
|
323
|
+
act = activity(line)
|
|
324
|
+
if act:
|
|
325
|
+
secs = int(time.monotonic() - started)
|
|
326
|
+
progress.info(" ▸ %s · %s · %dm%02ds", req.label or "session", act, secs // 60, secs % 60)
|
|
327
|
+
return watch(line)
|
|
328
|
+
|
|
329
|
+
p = None
|
|
330
|
+
try:
|
|
331
|
+
p = stream_proc(cmd, input=prompt, cwd=req.cwd, env=self.env(), timeout=req.timeout_sec, on_line=on_line)
|
|
332
|
+
except OSError as exc:
|
|
333
|
+
return SessionResult(ok=False, error=f"cannot start claude ({self.binary}): {exc}", infra="cli")
|
|
334
|
+
finally:
|
|
335
|
+
if log:
|
|
336
|
+
log.write(f"\n\nSTDERR:\n{p.stderr if p else ''}")
|
|
337
|
+
log.close()
|
|
338
|
+
if p.timed_out or p.stopped: # killed: no result event, so tokens come from the messages seen so far
|
|
339
|
+
usage = partial_usage(p.stdout, req.model)
|
|
340
|
+
killed = dict(ok=False, session_id=first_session_id(p.stdout), usage=usage,
|
|
341
|
+
cost=round(sum(u["cost"] for u in usage.values()), 6), window=window_info(p.stdout),
|
|
342
|
+
config=config_load(p.stdout))
|
|
343
|
+
if p.timed_out:
|
|
344
|
+
return SessionResult(error=f"session timed out after {req.timeout_sec}s", timed_out=True, **killed)
|
|
345
|
+
return SessionResult(error=p.stopped, stuck=True, **killed)
|
|
346
|
+
|
|
347
|
+
raw = p.stdout.strip()
|
|
348
|
+
data = result_event(raw)
|
|
349
|
+
text = str(data.get("result", "") or ("" if data else raw)) # never the whole event stream as "text"
|
|
350
|
+
cost = float(data.get("total_cost_usd") or data.get("cost_usd") or 0.0)
|
|
351
|
+
sub = str(data.get("subtype") or "") # e.g. error_max_turns, error_max_budget_usd
|
|
352
|
+
is_error = bool(data.get("is_error")) or sub.startswith("error") or p.rc != 0 or not data
|
|
353
|
+
err = "" if not is_error else ((f"{sub}: " if sub.startswith("error") else "") + (text or p.stderr))[-3000:]
|
|
354
|
+
report = data.get("structured_output") if isinstance(data.get("structured_output"), dict) else parse_report(text)
|
|
355
|
+
limited, reset_at = detect_limit(text + "\n" + p.stderr) if is_error else (False, None)
|
|
356
|
+
infra = ""
|
|
357
|
+
if is_error and not limited: # no output at all = a CLI that can't run (seen: a broken npm install)
|
|
358
|
+
infra = detect_infra(text + "\n" + p.stderr) or ("cli" if not raw and not p.stderr.strip() else "")
|
|
359
|
+
totals = {"cost": cost, "usage": parse_usage(data, req.model),
|
|
360
|
+
"num_turns": _num(data.get("num_turns")), "duration_ms": _num(data.get("duration_ms"))}
|
|
361
|
+
own = since(totals, req.resume_totals) if req.resume else totals
|
|
362
|
+
return SessionResult(
|
|
363
|
+
ok=not is_error, text=text, cost=own["cost"], session_id=str(data.get("session_id", "")),
|
|
364
|
+
report=report or {}, error=err or (f"{infra} failure" if infra else ""), rate_limited=limited, reset_at=reset_at, infra=infra,
|
|
365
|
+
usage=own["usage"],
|
|
366
|
+
num_turns=own["num_turns"], duration_ms=own["duration_ms"], totals=totals, window=window_info(raw),
|
|
367
|
+
config=config_load(raw),
|
|
368
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Generic backend: any CLI agent (Aider, Codex, a LiteLLM wrapper...). Prompt on stdin, report in stdout."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..proc import agent_env, run_proc
|
|
10
|
+
from . import SessionRequest, SessionResult, detect_limit, parse_report
|
|
11
|
+
|
|
12
|
+
# M4: documented invocations of other agent CLIs (docs checked 2026-10-01, not yet run live). Each gets a short argument and reads the
|
|
13
|
+
# real prompt from stdin (piped stdin is appended to the argument). Double quotes work in sh and cmd.exe alike.
|
|
14
|
+
# ponytail: no read-only variants; read-only sessions run on throwaway branches whose changes are dropped anyway.
|
|
15
|
+
_ASK = '"Your full instructions are on standard input. Follow them and end with the requested JSON report."'
|
|
16
|
+
PRESETS = {
|
|
17
|
+
"codex": "codex exec --sandbox workspace-write --skip-git-repo-check{model_flag} " + _ASK,
|
|
18
|
+
"gemini": "gemini --approval-mode=yolo{model_flag} -p " + _ASK,
|
|
19
|
+
"opencode": "opencode run --auto{model_flag} " + _ASK,
|
|
20
|
+
# P03-T01 (N3): docs checked 2026-10-11, not yet run live. Sources: https://kiro.dev/docs/cli/headless/ ,
|
|
21
|
+
# https://cursor.com/docs/cli/headless , https://cursor.com/docs/cli/reference/parameters ,
|
|
22
|
+
# https://aider.chat/docs/config/options.html , https://aider.chat/docs/scripting.html
|
|
23
|
+
# kiro: no _ASK, because a positional argument stops kiro-cli from reading the prompt on stdin.
|
|
24
|
+
# cursor: stdin is not documented (the Chief project, PR #48, uses it), so the short _ASK argument stays.
|
|
25
|
+
# aider: no stdin prompt, so it reads {prompt_file} (a temp file outside the worktree); git stays with the orchestrator.
|
|
26
|
+
# --trust-all-tools (kiro) and --force (cursor) approve every tool, not only the workspace (like gemini yolo).
|
|
27
|
+
"kiro": "kiro-cli chat --no-interactive --trust-all-tools{model_flag}",
|
|
28
|
+
"cursor": "cursor-agent -p --force --trust{model_flag} " + _ASK,
|
|
29
|
+
"aider": "aider --message-file {prompt_file} --yes-always --no-auto-commits --no-dirty-commits --no-gitignore"
|
|
30
|
+
" --no-check-update --no-analytics --no-pretty --no-stream{model_flag}",
|
|
31
|
+
}
|
|
32
|
+
_MODEL_OPT = {"kiro": "--model", "cursor": "--model", "aider": "--model"} # P03-T01: aider -m means --message
|
|
33
|
+
CLAUDE_ALIASES = ("haiku", "sonnet", "opus")
|
|
34
|
+
SAFE_MODEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def command_template(cfg) -> str:
|
|
38
|
+
return cfg.get("agent.command") or PRESETS.get(str(cfg.get("agent.preset") or ""), "")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def model_flag(cfg, model: str) -> str:
|
|
42
|
+
"""` -m <model>` (` --model <model>` for kiro, cursor, aider) after `agent.model_map` (ladder names → this CLI's
|
|
43
|
+
models); an unmapped Claude alias gets no flag, so the CLI uses its own default model."""
|
|
44
|
+
m = str((cfg.get("agent.model_map", {}) or {}).get(model, model))
|
|
45
|
+
if not SAFE_MODEL.fullmatch(m): # the command runs through a shell (sh or cmd.exe): no quoting tricks needed
|
|
46
|
+
raise ValueError(f"unsafe model name for agent.preset: {m!r}")
|
|
47
|
+
opt = _MODEL_OPT.get(str(cfg.get("agent.preset") or ""), "-m")
|
|
48
|
+
return "" if m in CLAUDE_ALIASES else f" {opt} {m}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CommandBackend:
|
|
52
|
+
def __init__(self, cfg):
|
|
53
|
+
self.cfg = cfg
|
|
54
|
+
self.template = command_template(cfg)
|
|
55
|
+
|
|
56
|
+
def run(self, req: SessionRequest) -> SessionResult:
|
|
57
|
+
# the project context lives in system_append (U7); a generic CLI has no system prompt, so it leads the prompt
|
|
58
|
+
prompt = f"{req.system_append}\n\n---\n\n{req.prompt}" if req.system_append else req.prompt
|
|
59
|
+
pf = ""
|
|
60
|
+
if "{prompt_file}" in self.template: # P03-T01: for CLIs without a stdin prompt (aider); outside the worktree
|
|
61
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", prefix="autopilot-prompt-", suffix=".md",
|
|
62
|
+
delete=False) as f:
|
|
63
|
+
f.write(prompt)
|
|
64
|
+
pf = f.name
|
|
65
|
+
try:
|
|
66
|
+
return self._run(req, prompt, pf)
|
|
67
|
+
finally:
|
|
68
|
+
if pf: Path(pf).unlink(missing_ok=True)
|
|
69
|
+
|
|
70
|
+
def _run(self, req: SessionRequest, prompt: str, pf: str) -> SessionResult:
|
|
71
|
+
cmd = self.template.format(model=shlex.quote(req.model), budget=req.budget_usd or 0,
|
|
72
|
+
read_only=int(req.read_only), model_flag=model_flag(self.cfg, req.model),
|
|
73
|
+
prompt_file=f'"{pf}"')
|
|
74
|
+
env = {**agent_env(self.cfg), "AUTOPILOT_MODEL": req.model, "AUTOPILOT_READ_ONLY": str(int(req.read_only)),
|
|
75
|
+
"AUTOPILOT_SYSTEM_APPEND": req.system_append}
|
|
76
|
+
env.update({"AUTODEV_" + k[10:]: v for k, v in list(env.items()) if k.startswith("AUTOPILOT_")})
|
|
77
|
+
try:
|
|
78
|
+
p = run_proc(cmd, shell=True, input=prompt, cwd=req.cwd, env=env, timeout=req.timeout_sec)
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
return SessionResult(ok=False, error=f"cannot start agent command: {exc}")
|
|
81
|
+
if p.timed_out:
|
|
82
|
+
return SessionResult(ok=False, error=f"session timed out after {req.timeout_sec}s", timed_out=True)
|
|
83
|
+
out = p.stdout
|
|
84
|
+
if req.log_path:
|
|
85
|
+
Path(req.log_path).parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
Path(req.log_path).write_text(f"$ {cmd}\n\nSTDOUT:\n{out}\n\nSTDERR:\n{p.stderr}", encoding="utf-8")
|
|
87
|
+
err = "" if p.rc == 0 else (p.stderr or out)[-3000:]
|
|
88
|
+
limited, reset_at = detect_limit(p.stderr) if p.rc else (False, None)
|
|
89
|
+
return SessionResult(ok=p.rc == 0, text=out, report=parse_report(out), error=err,
|
|
90
|
+
rate_limited=limited, reset_at=reset_at)
|