subtask-mcp 0.2.3__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 (50) hide show
  1. subtask/__init__.py +3 -0
  2. subtask/admission.py +254 -0
  3. subtask/capacity.py +90 -0
  4. subtask/cli.py +183 -0
  5. subtask/config.py +59 -0
  6. subtask/core.py +603 -0
  7. subtask/doctor.py +120 -0
  8. subtask/engines/__init__.py +1 -0
  9. subtask/engines/base.py +297 -0
  10. subtask/engines/claude_api.py +204 -0
  11. subtask/engines/codex.py +182 -0
  12. subtask/engines/demo.py +50 -0
  13. subtask/engines/dsh.py +96 -0
  14. subtask/engines/kimi.py +75 -0
  15. subtask/engines/opencode.py +87 -0
  16. subtask/engines/registry.py +124 -0
  17. subtask/events.py +105 -0
  18. subtask/evidence.py +103 -0
  19. subtask/fastserver.py +272 -0
  20. subtask/gc.py +99 -0
  21. subtask/install/__init__.py +1 -0
  22. subtask/install/clients.py +355 -0
  23. subtask/install/commands.py +122 -0
  24. subtask/install/doctor.py +112 -0
  25. subtask/install/fileops.py +69 -0
  26. subtask/install/installer.py +204 -0
  27. subtask/install/ownership.py +44 -0
  28. subtask/install/tomledit.py +44 -0
  29. subtask/jobs.py +256 -0
  30. subtask/ledger.py +268 -0
  31. subtask/narrate.py +134 -0
  32. subtask/policy.py +58 -0
  33. subtask/procid.py +97 -0
  34. subtask/progress.py +152 -0
  35. subtask/redact.py +61 -0
  36. subtask/roi.py +84 -0
  37. subtask/router.py +202 -0
  38. subtask/runners.py +99 -0
  39. subtask/schemacheck.py +131 -0
  40. subtask/server.py +192 -0
  41. subtask/skill/SKILL.md +96 -0
  42. subtask/telemetry.py +76 -0
  43. subtask/tools.json +237 -0
  44. subtask/worker.py +278 -0
  45. subtask_mcp-0.2.3.dist-info/METADATA +164 -0
  46. subtask_mcp-0.2.3.dist-info/RECORD +50 -0
  47. subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
  48. subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
  49. subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
  50. subtask_mcp-0.2.3.dist-info/top_level.txt +1 -0
subtask/doctor.py ADDED
@@ -0,0 +1,120 @@
1
+ """Diagnostics without spending: platform, tools, engines, state, protocol.
2
+
3
+ Each check says what was checked and how: installed, configured and
4
+ authenticated are different facts, and quota or sandbox availability are
5
+ reported separately. Nothing here calls a paid API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import platform
13
+ import shutil
14
+ import stat
15
+ import sys
16
+
17
+ from . import __version__, config
18
+
19
+
20
+ def _check(name: str, ok: bool | None, detail: str) -> dict:
21
+ return {"check": name, "ok": ok, "detail": detail}
22
+
23
+
24
+ def engines() -> list[dict]:
25
+ from .engines import registry
26
+
27
+ out = []
28
+ for name, adapter in registry.all_engines().items():
29
+ probe = adapter.probe()
30
+ out.append(
31
+ {
32
+ "engine": name,
33
+ "installed": probe.installed,
34
+ "version": probe.version,
35
+ "compatible": probe.compatible,
36
+ "auth": probe.auth,
37
+ "problems": probe.problems,
38
+ "capabilities": adapter.capabilities.as_dict(),
39
+ "capacity": adapter.capacity().as_dict() if probe.installed else None,
40
+ }
41
+ )
42
+ for err in registry.plugin_errors():
43
+ out.append({"engine": "plugin", "installed": False, "problems": [err]})
44
+ return out
45
+
46
+
47
+ def _bwrap() -> bool:
48
+ from .runners import bwrap_available
49
+
50
+ return bwrap_available()
51
+
52
+
53
+ def checks() -> list[dict]:
54
+ out = [
55
+ _check("subtask", True, f"{__version__} on Python {platform.python_version()} ({sys.executable})"),
56
+ _check(
57
+ "platform",
58
+ sys.platform.startswith("linux") or None,
59
+ f"{platform.system()} {platform.release()}: Linux supported; macOS degraded (no /proc start times, "
60
+ "no bwrap); Windows not supported (fcntl)",
61
+ ),
62
+ _check("git", bool(shutil.which("git")), shutil.which("git") or "git not found: repo= jobs need it"),
63
+ _check(
64
+ "bwrap",
65
+ bool(shutil.which("bwrap")) or None,
66
+ "available: policy=isolated works"
67
+ if shutil.which("bwrap")
68
+ else "missing: policy=isolated refused",
69
+ ),
70
+ _check("socat", bool(shutil.which("socat")) or None, "Claude Code sandbox network proxy on Linux"),
71
+ ]
72
+ env = config.ENV_FILE
73
+ if env.is_file():
74
+ mode = stat.S_IMODE(env.stat().st_mode)
75
+ out.append(_check("secret file", mode & 0o077 == 0, f"{env} mode {oct(mode)} (want 600)"))
76
+ else:
77
+ out.append(_check("secret file", None, f"{env} missing: *-api engines unavailable"))
78
+ try:
79
+ config.HOME.mkdir(parents=True, exist_ok=True)
80
+ writable = os.access(config.HOME, os.W_OK)
81
+ except OSError:
82
+ writable = False
83
+ out.append(_check("state dir", writable, str(config.HOME)))
84
+ try:
85
+ from . import admission, ledger
86
+
87
+ out.append(
88
+ _check("active jobs", True, f"{len(admission.active())} of {config.MAX_RUNNING} slots used")
89
+ )
90
+ out.append(
91
+ _check("spend", True, f"{ledger.committed():.2f}$ committed of {config.MONTHLY_CAP_USD:.0f}$")
92
+ )
93
+ except Exception as exc: # noqa: BLE001
94
+ out.append(_check("state", False, f"{type(exc).__name__}: {exc}"))
95
+ out.append(
96
+ _check(
97
+ "mcp protocol", True, "dual-era: 2026-07-28 (stateless) and 2025-11-25..2024-11-05 (initialize)"
98
+ )
99
+ )
100
+ return out
101
+
102
+
103
+ def report(as_json: bool = False) -> str:
104
+ data = {"checks": checks(), "engines": engines()}
105
+ if as_json:
106
+ return json.dumps(data, indent=1, default=str)
107
+ mark = {True: "ok ", False: "BAD", None: "-- "}
108
+ lines = [f"{mark[c['ok']]} {c['check']}: {c['detail']}" for c in data["checks"]]
109
+ for e in data["engines"]:
110
+ if e["engine"] == "plugin":
111
+ lines.append(f"BAD plugin: {'; '.join(e['problems'])}")
112
+ continue
113
+ state = "ok " if e["installed"] and e["compatible"] else "-- " if not e["installed"] else "BAD"
114
+ cap = e.get("capacity") or {}
115
+ free = "?" if cap.get("value") is None else f"{cap['value']:.0f}%"
116
+ lines.append(
117
+ f"{state} {e['engine']:12} {e['version'] or 'not installed'} · auth {e['auth']} · free {free} "
118
+ f"({cap.get('confidence', 'none')}) {'; '.join(e['problems'])}"[:200]
119
+ )
120
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ """Engine adapters. `registry.get(name)` returns one; the core knows no provider flags."""
@@ -0,0 +1,297 @@
1
+ """Engine adapter protocol: what the core may ask of an engine, and nothing about MCP or chat.
2
+
3
+ An adapter declares capabilities, probes the local install, reads capacity,
4
+ and runs one attempt. Third-party adapters register through the
5
+ `subtask.engines` entry point group (see registry.py); the core never changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable, Iterable
17
+ from pathlib import Path
18
+
19
+ ADAPTER_API = 1 # bump when the protocol below changes incompatibly
20
+
21
+ # Security levels, weakest first. A job asks for a minimum; adapters list what they enforce.
22
+ LEVELS = ("worktree", "sandbox", "isolated")
23
+
24
+
25
+ class _Factory:
26
+ def __init__(self, make: Callable[[], object]) -> None:
27
+ self.make = make
28
+
29
+
30
+ def field(default_factory: Callable[[], object]) -> object:
31
+ return _Factory(default_factory)
32
+
33
+
34
+ def record(cls: type) -> type:
35
+ """A tiny stand-in for @dataclass: `dataclasses` pulls in `inspect` (~10 ms per cold start)."""
36
+ names = list(cls.__annotations__) # own annotations (strings: postponed evaluation)
37
+ defaults = {n: cls.__dict__[n] for n in names if n in cls.__dict__}
38
+
39
+ def __init__(self, *args: object, **kwargs: object) -> None:
40
+ values = dict(zip(names, args, strict=False))
41
+ values.update(kwargs)
42
+ for name in names:
43
+ if name not in values:
44
+ if name not in defaults:
45
+ raise TypeError(f"{cls.__name__}() missing argument {name!r}")
46
+ d = defaults[name]
47
+ values[name] = d.make() if isinstance(d, _Factory) else d
48
+ object.__setattr__(self, name, values[name])
49
+
50
+ def as_dict(self) -> dict:
51
+ return {n: getattr(self, n) for n in names}
52
+
53
+ cls.__init__ = __init__
54
+ cls.__eq__ = lambda self, other: type(other) is type(self) and as_dict(self) == as_dict(other)
55
+ cls.__repr__ = lambda self: f"{cls.__name__}({as_dict(self)!r})"
56
+ if "as_dict" not in cls.__dict__:
57
+ cls.as_dict = as_dict
58
+ cls._fields = tuple(names)
59
+ return cls
60
+
61
+
62
+ def asdict(obj: object) -> dict:
63
+ return {n: getattr(obj, n) for n in obj._fields} # type: ignore[attr-defined]
64
+
65
+
66
+ @record
67
+ class Capabilities:
68
+ readonly: bool # can enforce a read-only run
69
+ levels: tuple[str, ...] # write isolation levels the adapter can enforce
70
+ command_network_off: bool # can deny network to the worker's shell commands
71
+ cost: str # "usd" observed, "quota" (subscription share), "none"
72
+ resume: bool
73
+ structured_events: bool
74
+ paid: bool # books money against the monthly cap
75
+ notes: str = ""
76
+
77
+ def as_dict(self) -> dict:
78
+ return asdict(self)
79
+
80
+
81
+ @record
82
+ class Probe:
83
+ installed: bool
84
+ version: str | None = None
85
+ path: str | None = None
86
+ flags: dict[str, bool] = field(default_factory=dict)
87
+ auth: str = "unknown" # ok | missing | unknown: from local evidence only, never a paid call
88
+ problems: list[str] = field(default_factory=list)
89
+ checked: float = field(default_factory=time.time)
90
+ compatible: bool = True
91
+
92
+ def as_dict(self) -> dict:
93
+ return asdict(self)
94
+
95
+
96
+ @record
97
+ class Capacity:
98
+ value: float | None # None = unknown, never a silent 50
99
+ unit: str # "pct_free" | "usd_left"
100
+ source: str
101
+ ts: float
102
+ confidence: str # high | medium | low | none
103
+ note: str = ""
104
+ resets_at: float | None = None
105
+
106
+ @property
107
+ def freshness_s(self) -> float:
108
+ return max(0.0, time.time() - self.ts)
109
+
110
+ def as_dict(self) -> dict:
111
+ return {**asdict(self), "freshness_s": round(self.freshness_s, 1)}
112
+
113
+
114
+ @record
115
+ class RunContext:
116
+ job_id: str
117
+ spec: dict
118
+ prompt: str
119
+ resume: str | None
120
+ attempt: int
121
+ folder: Path
122
+ emit: Callable[..., object] # emit(phase, msg, source="adapter")
123
+ started: threading.Event # set once the engine may have spent anything
124
+ wrap: Callable[[list[str], dict[str, str]], tuple[list[str], dict[str, str]]] # isolation runner
125
+ env: dict[str, str] # minimal environment for the engine process
126
+
127
+
128
+ @record
129
+ class Outcome:
130
+ ok: bool
131
+ subtype: str
132
+ result: str
133
+ session_id: str | None
134
+ cost_usd: float | None = None # None = unknown
135
+ turns: int | None = None
136
+ tokens: dict[str, int] = field(default_factory=dict)
137
+ commands: list[dict] = field(default_factory=list) # runner_observed: {"command", "exit_code"}
138
+ quota_exhausted: bool = False
139
+
140
+
141
+ class Adapter:
142
+ """Base class. Subclasses set the class attributes and override build/parse."""
143
+
144
+ name = ""
145
+ family = ""
146
+ binary: str | None = None
147
+ model: str | None = None
148
+ supported = "" # human description of tested versions, e.g. ">=2.1"
149
+ capabilities: Capabilities
150
+
151
+ def probe(self) -> Probe:
152
+ if not self.binary:
153
+ return Probe(installed=True, version="sdk", path=None)
154
+ path = shutil.which(self.binary)
155
+ if not path:
156
+ return Probe(installed=False, problems=[f"{self.binary} not on PATH"])
157
+ version = _run([path, "--version"])
158
+ probe = Probe(installed=True, version=version.splitlines()[0][:80] if version else None, path=path)
159
+ help_text = _run([path, *self.help_args()]) if self.help_args() else ""
160
+ for flag in self.required_flags():
161
+ probe.flags[flag] = flag in help_text
162
+ if help_text and flag not in help_text:
163
+ probe.problems.append(f"flag {flag} not in `{self.binary} {' '.join(self.help_args())}`")
164
+ probe.compatible = not probe.problems
165
+ probe.auth = self.auth_state()
166
+ return probe
167
+
168
+ def available(self) -> bool:
169
+ """Cheap installed check for routing (no subprocess): binary on PATH or module importable."""
170
+ if self.binary:
171
+ return shutil.which(self.binary) is not None
172
+ module = getattr(self, "module", None)
173
+ if module:
174
+ import importlib.util
175
+
176
+ return importlib.util.find_spec(module) is not None
177
+ return True
178
+
179
+ def help_args(self) -> list[str]:
180
+ return []
181
+
182
+ def required_flags(self) -> list[str]:
183
+ return []
184
+
185
+ def auth_state(self) -> str:
186
+ return "unknown"
187
+
188
+ def capacity(self) -> Capacity:
189
+ return Capacity(None, "pct_free", "none", time.time(), "none", "no quota data")
190
+
191
+ def run(self, ctx: RunContext) -> Outcome:
192
+ raise NotImplementedError
193
+
194
+
195
+ class CliAdapter(Adapter):
196
+ """An engine driven as a headless CLI that prints JSON lines on stdout."""
197
+
198
+ def argv(self, ctx: RunContext) -> list[str]:
199
+ raise NotImplementedError
200
+
201
+ def parse(self, line: str, state: dict) -> str | None:
202
+ """Update `state` from one stdout line; return a short activity text or None."""
203
+ return None
204
+
205
+ def run(self, ctx: RunContext) -> Outcome:
206
+ argv, env = ctx.wrap(self.argv(ctx), dict(ctx.env))
207
+ state: dict = {"text": [], "commands": [], "session": None, "tokens": {}, "cost": None}
208
+ raw = ctx.folder / f"output-{ctx.attempt}.jsonl"
209
+ with raw.open("a") as keep, (ctx.folder / "worker-stderr.log").open("a") as err:
210
+ proc = subprocess.Popen(
211
+ argv,
212
+ cwd=ctx.spec["cwd"],
213
+ stdin=subprocess.DEVNULL,
214
+ stdout=subprocess.PIPE,
215
+ stderr=err,
216
+ text=True,
217
+ env=env,
218
+ )
219
+ ctx.started.set()
220
+ assert proc.stdout is not None
221
+ last_emit = 0.0
222
+ for line in proc.stdout:
223
+ keep.write(line)
224
+ if not line.lstrip().startswith("{"):
225
+ state.setdefault("plain", []).append(line.rstrip("\n"))
226
+ try:
227
+ activity = self.parse(line, state)
228
+ except (ValueError, KeyError, TypeError, AttributeError):
229
+ activity = None
230
+ state.setdefault("format_errors", 0)
231
+ state["format_errors"] += 1
232
+ if activity and time.monotonic() - last_emit >= 1.0:
233
+ ctx.emit("tool", activity, source="adapter")
234
+ last_emit = time.monotonic()
235
+ code = proc.wait()
236
+ text = "\n".join(state["text"]).strip()
237
+ if not text and state.get("plain"):
238
+ text = "\n".join(state["plain"]).strip() # engine printed no structured events: keep its output
239
+ if state.get("error") and not text:
240
+ text = state["error"]
241
+ return Outcome(
242
+ ok=code == 0 and not state.get("error") and bool(text),
243
+ subtype=state.get("error_kind") or f"exit {code}",
244
+ result=text[-20000:],
245
+ session_id=state.get("session")
246
+ or ctx.resume
247
+ or ("continue" if self.capabilities.resume else None),
248
+ cost_usd=state.get("cost"),
249
+ tokens=state["tokens"],
250
+ commands=state["commands"],
251
+ quota_exhausted=bool(state.get("quota")),
252
+ )
253
+
254
+
255
+ def _run(argv: list[str], timeout: float = 10) -> str:
256
+ try:
257
+ done = subprocess.run(
258
+ argv, capture_output=True, text=True, timeout=timeout, check=False, stdin=subprocess.DEVNULL
259
+ )
260
+ except (OSError, subprocess.TimeoutExpired):
261
+ return ""
262
+ return (done.stdout or done.stderr or "").strip()
263
+
264
+
265
+ def short_command(command: object) -> str:
266
+ """A shell command as one short line, for progress (never full scripts)."""
267
+ if isinstance(command, list):
268
+ command = " ".join(str(c) for c in command)
269
+ text = str(command or "").strip()
270
+ for prefix in ("bash -lc ", "/bin/bash -lc ", "sh -c ", "/bin/sh -c "):
271
+ if text.startswith(prefix):
272
+ text = text[len(prefix) :].strip("'\"")
273
+ first = text.splitlines()[0] if text else ""
274
+ return first[:70] + ("…" if len(first) > 70 or "\n" in text else "")
275
+
276
+
277
+ def describe_command(command: str) -> str:
278
+ low = command.lower()
279
+ if any(k in low for k in ("pytest", "npm test", "go test", "cargo test", "unittest", "jest", "vitest")):
280
+ return f"running tests: {command}"
281
+ if low.startswith(("git commit", "git add")):
282
+ return f"committing: {command}"
283
+ if low.startswith(("rg ", "grep ", "find ", "ls ", "cat ", "sed -n", "head ", "tail ")):
284
+ return f"reading: {command}"
285
+ return f"running: {command}"
286
+
287
+
288
+ def json_line(line: str) -> dict | None:
289
+ line = line.strip()
290
+ if not line.startswith("{"):
291
+ return None
292
+ data = json.loads(line)
293
+ return data if isinstance(data, dict) else None
294
+
295
+
296
+ def env_names(names: Iterable[str]) -> dict[str, str]:
297
+ return {k: os.environ[k] for k in names if k in os.environ}
@@ -0,0 +1,204 @@
1
+ """Claude models through the Claude Agent SDK, paid with the API key (monthly cap)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from .. import config
12
+ from .base import Adapter, Capabilities, Capacity, Outcome, RunContext, describe_command, short_command
13
+
14
+ READONLY_TOOLS = ["Read", "Grep", "Glob", "WebSearch"]
15
+ SUMMARY_RULE = (
16
+ "\n\nWhen you finish, end your answer with a section that starts with the exact "
17
+ 'line "## Summary" and has at most 10 short lines. Put the answer itself first '
18
+ "(the findings, the decision, the numbers), then files touched, commits, tests "
19
+ "run, what is left. The caller reads only this section. Use the task language."
20
+ )
21
+
22
+
23
+ def key_helper() -> Path:
24
+ """Script that prints the key, so the key is never in the agent's environment."""
25
+ helper = config.HOME / "key-helper.sh"
26
+ helper.parent.mkdir(parents=True, exist_ok=True)
27
+ tmp = helper.with_suffix(f".{os.getpid()}.tmp")
28
+ tmp.write_text(
29
+ f"#!{sys.executable}\nfrom subtask.config import load_secrets\nprint(load_secrets().get('ANTHROPIC_API_KEY', ''))\n"
30
+ )
31
+ tmp.chmod(0o700)
32
+ os.replace(tmp, helper) # atomic: other workers may be running it right now
33
+ return helper
34
+
35
+
36
+ def settings(sandbox: bool, git_dir: str | None, network_off: bool = False) -> str:
37
+ """Per-run settings: OS sandbox for Bash, and best-effort read denies on secrets.
38
+
39
+ Not a hard boundary: an agent running as the same user can still find a way to
40
+ read files it may not Read. Use level="isolated" for a filesystem boundary.
41
+ """
42
+ secret_paths = [
43
+ str(config.ENV_FILE),
44
+ str(config.HOME / "key-helper.sh"),
45
+ str(Path.home() / ".claude" / ".credentials.json"),
46
+ ]
47
+ data: dict = {
48
+ "permissions": {
49
+ "deny": [f"{tool}(/{p})" for p in secret_paths for tool in ("Read", "Edit", "Write")]
50
+ + [f"Bash(*{p}*)" for p in secret_paths]
51
+ + [f"Bash(*{p.replace(str(Path.home()), '~')}*)" for p in secret_paths]
52
+ },
53
+ "apiKeyHelper": str(key_helper()),
54
+ }
55
+ if sandbox:
56
+ data["sandbox"] = {
57
+ "enabled": True,
58
+ "allowUnsandboxedCommands": False,
59
+ "failIfUnavailable": True,
60
+ "filesystem": {
61
+ # git data of a worktree lives in the main repo's .git: commits need it
62
+ "allowWrite": [str(Path.home() / ".cache")] + ([git_dir] if git_dir else []),
63
+ "denyRead": secret_paths,
64
+ "denyWrite": secret_paths,
65
+ },
66
+ }
67
+ if network_off:
68
+ data["sandbox"]["network"] = {"allowedDomains": []}
69
+ return json.dumps(data)
70
+
71
+
72
+ def api_env() -> dict[str, str]:
73
+ env = {"IS_SANDBOX": "1", "ANTHROPIC_API_KEY": ""} # key comes from apiKeyHelper only
74
+ secrets = config.load_secrets()
75
+ if not secrets.get("ANTHROPIC_API_KEY"):
76
+ raise RuntimeError(f"ANTHROPIC_API_KEY missing (file {config.ENV_FILE})")
77
+ if secrets.get("ANTHROPIC_WORKSPACE_ID"):
78
+ env["ANTHROPIC_CUSTOM_HEADERS"] = f"anthropic-workspace-id: {secrets['ANTHROPIC_WORKSPACE_ID']}"
79
+ return env
80
+
81
+
82
+ class ClaudeApi(Adapter):
83
+ family = "api"
84
+ module = "claude_agent_sdk"
85
+ capabilities = Capabilities(
86
+ readonly=True,
87
+ levels=("worktree", "sandbox"),
88
+ command_network_off=True,
89
+ cost="usd",
90
+ resume=True,
91
+ structured_events=True,
92
+ paid=True,
93
+ notes="sandbox = Claude Code OS sandbox (bubblewrap on Linux), same user",
94
+ )
95
+
96
+ def __init__(self, name: str, model: str) -> None:
97
+ self.name, self.model = name, model
98
+
99
+ def auth_state(self) -> str:
100
+ return "present" if config.load_secrets().get("ANTHROPIC_API_KEY") else "missing"
101
+
102
+ def probe(self):
103
+ probe = super().probe()
104
+ try:
105
+ import importlib.metadata as md
106
+
107
+ probe.version = "claude-agent-sdk " + md.version("claude-agent-sdk")
108
+ except Exception: # noqa: BLE001
109
+ probe.installed, probe.problems = False, ["claude-agent-sdk not installed"]
110
+ probe.auth = self.auth_state()
111
+ return probe
112
+
113
+ def capacity(self) -> Capacity:
114
+ from .. import ledger
115
+
116
+ cap = config.MONTHLY_CAP_USD
117
+ left = cap - ledger.committed()
118
+ return Capacity(
119
+ max(0.0, 100 * left / cap) if cap > 0 else 0.0,
120
+ "pct_free",
121
+ "local ledger vs monthly cap",
122
+ time.time(),
123
+ "high",
124
+ f"{left:.2f}$ of {cap:.0f}$ left this month",
125
+ )
126
+
127
+ def run(self, ctx: RunContext) -> Outcome:
128
+ import asyncio # lazy: 60+ ms at import, only the worker needs it
129
+
130
+ return asyncio.run(self._run(ctx))
131
+
132
+ async def _run(self, ctx: RunContext) -> Outcome:
133
+ from claude_agent_sdk import (
134
+ AssistantMessage,
135
+ ClaudeAgentOptions,
136
+ ResultMessage,
137
+ ToolResultBlock,
138
+ ToolUseBlock,
139
+ UserMessage,
140
+ query,
141
+ )
142
+
143
+ spec = ctx.spec
144
+ readonly = spec.get("readonly", False)
145
+ policy = spec.get("policy") or {}
146
+ sandbox = not readonly and policy.get("level", "sandbox") != "worktree"
147
+ options = ClaudeAgentOptions(
148
+ cwd=spec["cwd"],
149
+ model=spec.get("model") or self.model,
150
+ max_turns=spec["max_turns"],
151
+ max_budget_usd=spec["max_usd"],
152
+ effort=spec["effort"],
153
+ permission_mode="dontAsk" if readonly else "bypassPermissions",
154
+ allowed_tools=READONLY_TOOLS if readonly else [],
155
+ disallowed_tools=["mcp__*", "WebFetch"] if readonly else ["mcp__*"],
156
+ settings=settings(sandbox, spec.get("git_dir"), policy.get("untrusted", False)),
157
+ system_prompt={"type": "preset", "preset": "claude_code", "append": SUMMARY_RULE},
158
+ env={**ctx.env, **api_env()},
159
+ resume=ctx.resume,
160
+ )
161
+ out = Outcome(ok=False, subtype="no result", result="", session_id=ctx.resume)
162
+ pending: dict[str, str] = {}
163
+ turns = 0
164
+ async for message in query(prompt=ctx.prompt, options=options):
165
+ ctx.started.set()
166
+ if isinstance(message, AssistantMessage):
167
+ turns += 1
168
+ for block in message.content:
169
+ if isinstance(block, ToolUseBlock):
170
+ text = _tool_text(block.name, block.input)
171
+ if block.name == "Bash":
172
+ pending[block.id] = short_command(block.input.get("command"))
173
+ ctx.emit("tool", text, source="adapter")
174
+ elif isinstance(message, UserMessage) and isinstance(message.content, list):
175
+ for block in message.content:
176
+ if isinstance(block, ToolResultBlock) and block.tool_use_id in pending:
177
+ out.commands.append(
178
+ {
179
+ "command": pending.pop(block.tool_use_id),
180
+ "exit_code": 1 if block.is_error else 0,
181
+ }
182
+ )
183
+ elif isinstance(message, ResultMessage):
184
+ usage = message.usage or {}
185
+ out = Outcome(
186
+ ok=not message.is_error and message.subtype == "success",
187
+ subtype=message.subtype,
188
+ result=message.result or "",
189
+ session_id=message.session_id,
190
+ cost_usd=message.total_cost_usd or 0.0,
191
+ turns=message.num_turns,
192
+ tokens={k: v for k, v in usage.items() if isinstance(v, int)},
193
+ commands=out.commands,
194
+ )
195
+ out.turns = out.turns or turns
196
+ return out
197
+
198
+
199
+ def _tool_text(name: str, args: dict) -> str:
200
+ if name == "Bash":
201
+ return describe_command(short_command(args.get("command")))
202
+ target = args.get("file_path") or args.get("path") or args.get("pattern") or args.get("query") or ""
203
+ verb = {"Read": "reading", "Edit": "editing", "Write": "writing", "Grep": "searching", "Glob": "listing"}
204
+ return f"{verb.get(name, name)} {Path(str(target)).name if '/' in str(target) else target}".strip()[:90]