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.
Files changed (79) hide show
  1. autopilot/__init__.py +4 -0
  2. autopilot/__main__.py +6 -0
  3. autopilot/backends/__init__.py +159 -0
  4. autopilot/backends/claude_cli.py +368 -0
  5. autopilot/backends/command.py +90 -0
  6. autopilot/cli.py +1614 -0
  7. autopilot/commands/__init__.py +0 -0
  8. autopilot/commands/preflight.py +131 -0
  9. autopilot/config.py +155 -0
  10. autopilot/context.py +414 -0
  11. autopilot/dash_term.py +493 -0
  12. autopilot/dash_web.py +347 -0
  13. autopilot/dashboard.py +410 -0
  14. autopilot/decisions.py +85 -0
  15. autopilot/deploy.py +67 -0
  16. autopilot/docs.py +301 -0
  17. autopilot/doctor.py +478 -0
  18. autopilot/gate.py +383 -0
  19. autopilot/github_util.py +42 -0
  20. autopilot/gitops.py +251 -0
  21. autopilot/growth.py +85 -0
  22. autopilot/handoff.py +27 -0
  23. autopilot/layers.py +134 -0
  24. autopilot/log_scrub.py +11 -0
  25. autopilot/mcp.py +104 -0
  26. autopilot/notify.py +98 -0
  27. autopilot/orchestrator.py +2214 -0
  28. autopilot/plain.py +238 -0
  29. autopilot/plan.py +320 -0
  30. autopilot/proc.py +176 -0
  31. autopilot/prompts/auditor.md +39 -0
  32. autopilot/prompts/decide.md +38 -0
  33. autopilot/prompts/docs.md +48 -0
  34. autopilot/prompts/fixer.md +26 -0
  35. autopilot/prompts/onboard.md +43 -0
  36. autopilot/prompts/plan_writer.md +40 -0
  37. autopilot/prompts/replanner.md +42 -0
  38. autopilot/prompts/research.md +25 -0
  39. autopilot/prompts/resume.md +10 -0
  40. autopilot/prompts/retry.md +8 -0
  41. autopilot/prompts/review.md +40 -0
  42. autopilot/prompts/system.md +16 -0
  43. autopilot/prompts/task.md +78 -0
  44. autopilot/prompts/triage.md +30 -0
  45. autopilot/prompts/unstick.md +37 -0
  46. autopilot/prompts/verify.md +32 -0
  47. autopilot/repomap.py +71 -0
  48. autopilot/report.py +633 -0
  49. autopilot/rights.py +184 -0
  50. autopilot/schemas.py +42 -0
  51. autopilot/state.py +298 -0
  52. autopilot/templates/__pycache__/cloudflare_apply.cpython-313.pyc +0 -0
  53. autopilot/templates/agent/BRAIN.md +21 -0
  54. autopilot/templates/agent/CLAUDE.md.snippet +10 -0
  55. autopilot/templates/agent/DECISIONS.md +5 -0
  56. autopilot/templates/agent/HANDOFF.md +3 -0
  57. autopilot/templates/agent/gitignore +10 -0
  58. autopilot/templates/agent/ide_rules.md +8 -0
  59. autopilot/templates/agent/plan.yaml +48 -0
  60. autopilot/templates/agent/project.yaml +259 -0
  61. autopilot/templates/cloudflare_apply.py +211 -0
  62. autopilot/templates/deploy.sh +72 -0
  63. autopilot/templates/email_verify.py +144 -0
  64. autopilot/templates/infra/cloudflare.yaml +11 -0
  65. autopilot/templates/presets/docker.yaml +4 -0
  66. autopilot/templates/presets/generic.yaml +3 -0
  67. autopilot/templates/presets/go.yaml +6 -0
  68. autopilot/templates/presets/java.yaml +4 -0
  69. autopilot/templates/presets/node.yaml +7 -0
  70. autopilot/templates/presets/python.yaml +6 -0
  71. autopilot/templates/presets/rust.yaml +5 -0
  72. autopilot/templates/presets/static.yaml +3 -0
  73. autopilot/upgrade.py +192 -0
  74. dev_autopilot-0.4.0.dist-info/METADATA +148 -0
  75. dev_autopilot-0.4.0.dist-info/RECORD +79 -0
  76. dev_autopilot-0.4.0.dist-info/WHEEL +5 -0
  77. dev_autopilot-0.4.0.dist-info/entry_points.txt +3 -0
  78. dev_autopilot-0.4.0.dist-info/licenses/LICENSE +21 -0
  79. dev_autopilot-0.4.0.dist-info/top_level.txt +1 -0
autopilot/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Autopilot — autonomous, plan-driven development orchestrator for Claude Code."""
2
+
3
+ __version__ = "0.4.0"
4
+ AGENT_DIR = ".agent"
autopilot/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m autopilot`: works even when the `autopilot` command is not on PATH."""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ sys.exit(main())
@@ -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)