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
@@ -0,0 +1,182 @@
1
+ """Codex CLI (`codex exec --json`). Tested with codex-cli 0.162."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import time
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from .base import Capabilities, Capacity, CliAdapter, RunContext, describe_command, json_line, short_command
12
+
13
+ LIMIT_RE = re.compile(
14
+ r"try again at ([A-Z][a-z]{2}) (\d{1,2})(?:st|nd|rd|th)?, (\d{4}) (\d{1,2}:\d{2} [AP]M)"
15
+ )
16
+
17
+
18
+ class Codex(CliAdapter):
19
+ name = "codex"
20
+ family = "codex"
21
+ binary = "codex"
22
+ supported = "codex-cli >=0.160"
23
+ capabilities = Capabilities(
24
+ readonly=True,
25
+ levels=("worktree", "sandbox", "isolated"),
26
+ command_network_off=True,
27
+ cost="quota",
28
+ resume=True,
29
+ structured_events=True,
30
+ paid=False,
31
+ notes="sandbox = codex workspace-write (same user); worktree = no codex sandbox, opt-in only",
32
+ )
33
+
34
+ def help_args(self) -> list[str]:
35
+ return ["exec", "--help"]
36
+
37
+ def required_flags(self) -> list[str]:
38
+ return ["--json", "--sandbox", "--output-last-message", "--add-dir", "--skip-git-repo-check"]
39
+
40
+ def auth_state(self) -> str:
41
+ return "present" if (Path.home() / ".codex" / "auth.json").is_file() else "missing"
42
+
43
+ def argv(self, ctx: RunContext) -> list[str]:
44
+ spec = ctx.spec
45
+ level = spec.get("policy", {}).get("level", "sandbox")
46
+ if spec.get("readonly"):
47
+ mode = ["-s", "read-only"]
48
+ elif level == "worktree":
49
+ mode = ["--dangerously-bypass-approvals-and-sandbox"]
50
+ else: # sandbox, isolated: codex's own workspace-write sandbox, network off for commands
51
+ mode = ["-s", "workspace-write", "-c", 'approval_policy="never"']
52
+ if spec.get("git_dir"):
53
+ mode += ["--add-dir", spec["git_dir"]] # commits need the main repo's .git
54
+ argv = ["codex", "exec", "--json", "--skip-git-repo-check", "-C", spec["cwd"], *mode]
55
+ argv += ["-o", str(ctx.folder / f"last-message-{ctx.attempt}.txt")]
56
+ if spec.get("model"):
57
+ argv += ["-m", spec["model"]]
58
+ argv += ["resume", ctx.resume, "--", ctx.prompt] if ctx.resume else ["--", ctx.prompt]
59
+ return argv
60
+
61
+ def parse(self, line: str, state: dict) -> str | None:
62
+ event = json_line(line)
63
+ if not event:
64
+ return None
65
+ kind = event.get("type")
66
+ if kind == "thread.started":
67
+ state["session"] = event.get("thread_id")
68
+ elif kind in ("item.started", "item.completed"):
69
+ item = event.get("item") or {}
70
+ if item.get("type") == "command_execution":
71
+ cmd = short_command(item.get("command"))
72
+ if kind == "item.completed":
73
+ state["commands"].append({"command": cmd, "exit_code": item.get("exit_code")})
74
+ return None
75
+ return describe_command(cmd)
76
+ if item.get("type") == "agent_message" and kind == "item.completed":
77
+ state["text"].append(item.get("text") or "")
78
+ if item.get("type") == "file_change" and kind == "item.completed":
79
+ files = [c.get("path", "") for c in item.get("changes") or []]
80
+ return "editing " + ", ".join(Path(f).name for f in files[:3])
81
+ elif kind == "turn.completed":
82
+ usage = event.get("usage") or {}
83
+ for key, value in usage.items():
84
+ if isinstance(value, int):
85
+ state["tokens"][key] = state["tokens"].get(key, 0) + value
86
+ elif kind in ("error", "turn.failed"):
87
+ message = event.get("message") or (event.get("error") or {}).get("message") or "error"
88
+ state["error"] = message
89
+ if "usage limit" in message.lower():
90
+ state["quota"] = True
91
+ state["error_kind"] = "quota exhausted"
92
+ state["resets_at"] = parse_reset(message)
93
+ return None
94
+
95
+ def run(self, ctx: RunContext):
96
+ outcome = super().run(ctx)
97
+ last = ctx.folder / f"last-message-{ctx.attempt}.txt"
98
+ if last.is_file() and last.read_text().strip():
99
+ outcome.result = last.read_text()[-20000:]
100
+ outcome.ok = outcome.ok or (outcome.subtype == "exit 0")
101
+ return outcome
102
+
103
+ def capacity(self) -> Capacity:
104
+ return codex_capacity()
105
+
106
+
107
+ def parse_reset(message: str) -> float | None:
108
+ match = LIMIT_RE.search(message)
109
+ if not match:
110
+ return None
111
+ try:
112
+ when = datetime.strptime(" ".join(match.groups()), "%b %d %Y %I:%M %p")
113
+ except ValueError:
114
+ return None
115
+ return when.timestamp()
116
+
117
+
118
+ def _tail(path: Path, size: int = 256 * 1024) -> str:
119
+ with path.open("rb") as fh:
120
+ fh.seek(0, 2)
121
+ fh.seek(max(0, fh.tell() - size))
122
+ return fh.read().decode(errors="replace")
123
+
124
+
125
+ def _find(obj: object, key: str) -> dict | None:
126
+ if isinstance(obj, dict):
127
+ if key in obj:
128
+ return obj[key]
129
+ for value in obj.values():
130
+ found = _find(value, key)
131
+ if found is not None:
132
+ return found
133
+ return None
134
+
135
+
136
+ def codex_capacity(root: Path | None = None) -> Capacity:
137
+ """Free share from the newest `rate_limits` in local Codex session files.
138
+
139
+ Couples to Codex's local storage format: when it changes, this says
140
+ "unknown" with the reason instead of guessing.
141
+ """
142
+ root = root or Path.home() / ".codex" / "sessions"
143
+ try:
144
+ files = sorted(root.glob("*/*/*/*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:5]
145
+ except OSError:
146
+ files = []
147
+ if not files:
148
+ return Capacity(None, "pct_free", "codex sessions", time.time(), "none", "no Codex session files")
149
+ for path in files:
150
+ for line in reversed(_tail(path).splitlines()):
151
+ if '"rate_limits"' not in line:
152
+ continue
153
+ try:
154
+ limits = _find(json.loads(line), "rate_limits")
155
+ except json.JSONDecodeError:
156
+ continue
157
+ worst = None
158
+ for key in ("primary", "secondary"):
159
+ win = (limits or {}).get(key)
160
+ if not win:
161
+ continue
162
+ expired = float(win.get("resets_at") or 0) < time.time()
163
+ used = 0.0 if expired else float(win.get("used_percent", 0))
164
+ if worst is None or used > worst[0]:
165
+ worst = (used, win.get("resets_at"))
166
+ if worst:
167
+ observed = path.stat().st_mtime
168
+ age = time.time() - observed
169
+ confidence = "high" if age < 3600 else "medium" if age < 86400 else "low"
170
+ when = time.strftime("%m-%d %H:%M", time.localtime(worst[1])) if worst[1] else "?"
171
+ return Capacity(
172
+ 100 - worst[0],
173
+ "pct_free",
174
+ f"codex session file ({path.name})",
175
+ observed,
176
+ confidence,
177
+ f"{worst[0]:.0f}% used (resets {when})",
178
+ resets_at=worst[1],
179
+ )
180
+ return Capacity(
181
+ None, "pct_free", "codex sessions", time.time(), "none", "no rate_limits block (format changed?)"
182
+ )
@@ -0,0 +1,50 @@
1
+ """Deterministic demo engine: no model, no network, no cost. Enabled with SUBTASK_DEMO=1.
2
+
3
+ It exists to show progress in a real client and to test the whole path.
4
+ The task text picks a script: "fail", "silent" (no activity details),
5
+ "long" (about 3 minutes), anything else succeeds in about 25 seconds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ from .base import Adapter, Capabilities, Capacity, Outcome, RunContext
13
+
14
+ STEPS = [
15
+ (4, "reading: rg -n 'def start' subtask/"),
16
+ (5, "editing core.py"),
17
+ (6, "running tests: pytest -q tests/test_core.py"),
18
+ (5, "committing: git commit -m 'demo'"),
19
+ ]
20
+
21
+
22
+ class Demo(Adapter):
23
+ name = "demo"
24
+ family = "demo"
25
+ capabilities = Capabilities(
26
+ readonly=True, levels=("worktree", "sandbox", "isolated"), command_network_off=True, cost="none",
27
+ resume=True, structured_events=True, paid=False, notes="demo: runs nothing, spends nothing",
28
+ ) # fmt: skip
29
+
30
+ def capacity(self) -> Capacity:
31
+ return Capacity(100.0, "pct_free", "demo", time.time(), "high", "demo engine")
32
+
33
+ def run(self, ctx: RunContext) -> Outcome:
34
+ task = ctx.prompt.lower()
35
+ steps = STEPS * 6 if "long" in task else STEPS
36
+ commands = []
37
+ for i, (seconds, text) in enumerate(steps):
38
+ if "silent" not in task:
39
+ ctx.emit("tool", text, source="adapter")
40
+ time.sleep(seconds)
41
+ if text.startswith("running tests"):
42
+ code = 1 if "fail" in task else 0
43
+ commands.append({"command": "pytest -q tests/test_core.py", "exit_code": code})
44
+ if code:
45
+ return Outcome(False, "tests failed", "## Summary\n- demo: tests failed (1 failing)", "demo",
46
+ commands=commands) # fmt: skip
47
+ if "fail" in task and i == 2:
48
+ break
49
+ return Outcome(True, "success", "## Summary\n- demo finished: 1 file changed, tests passed", "demo",
50
+ commands=commands) # fmt: skip
subtask/engines/dsh.py ADDED
@@ -0,0 +1,96 @@
1
+ """DeepSeek Harness SDK (optional dependency `subtask-mcp[dsh]`), paid from the monthly cap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+
8
+ from .. import config
9
+ from .base import Adapter, Capabilities, Capacity, Outcome, RunContext
10
+ from .claude_api import SUMMARY_RULE
11
+
12
+
13
+ def dsh_cost(events: list[dict]) -> float:
14
+ """USD from the usage of each model message; rates in config (DeepSeek list price)."""
15
+ total = 0.0
16
+ for event in events:
17
+ usage = event.get("data", {}).get("usage") if isinstance(event.get("data"), dict) else None
18
+ if not usage:
19
+ usage = event.get("usage")
20
+ if not isinstance(usage, dict):
21
+ continue
22
+ total += (
23
+ usage.get("inputTokens", 0) * config.DSH_INPUT_USD_M
24
+ + usage.get("cacheReadTokens", 0) * config.DSH_CACHED_USD_M
25
+ + usage.get("outputTokens", 0) * config.DSH_OUTPUT_USD_M
26
+ ) / 1e6
27
+ return total
28
+
29
+
30
+ class Dsh(Adapter):
31
+ name = "dsh"
32
+ family = "dsh"
33
+ module = "deepseek_harness"
34
+ model = "deepseek-v4-flash"
35
+ capabilities = Capabilities(
36
+ readonly=False,
37
+ levels=("worktree",),
38
+ command_network_off=False,
39
+ cost="usd",
40
+ resume=True,
41
+ structured_events=True,
42
+ paid=True,
43
+ notes="harness-dependent sandbox, not enforced by subtask",
44
+ )
45
+
46
+ def probe(self):
47
+ probe = super().probe()
48
+ try:
49
+ import importlib.metadata as md
50
+
51
+ probe.version = "deepseek-harness-sdk " + md.version("deepseek-harness-sdk")
52
+ except Exception: # noqa: BLE001
53
+ probe.installed, probe.problems = False, ["deepseek-harness-sdk not installed (extra: dsh)"]
54
+ probe.auth = "present" if config.deepseek_key() else "missing"
55
+ return probe
56
+
57
+ def capacity(self) -> Capacity:
58
+ from .. import ledger
59
+
60
+ cap = config.MONTHLY_CAP_USD
61
+ left = cap - ledger.committed()
62
+ return Capacity(
63
+ max(0.0, 100 * left / cap) if cap > 0 else 0.0, "pct_free", "local ledger vs monthly cap",
64
+ time.time(), "high", f"{left:.2f}$ of {cap:.0f}$ left this month",
65
+ ) # fmt: skip
66
+
67
+ def run(self, ctx: RunContext) -> Outcome:
68
+ from deepseek_harness import DeepSeekHarness
69
+
70
+ key = config.deepseek_key()
71
+ if not key:
72
+ raise RuntimeError(f"DeepSeek key missing (file {config.DSH_KEY_FILE})")
73
+ home = ctx.folder / "dsh-home"
74
+ home.mkdir(mode=0o700, exist_ok=True)
75
+ ctx.started.set()
76
+ with DeepSeekHarness(
77
+ cwd=ctx.spec["cwd"],
78
+ dsh_home=str(home),
79
+ model=self.model,
80
+ reasoning_effort=ctx.spec.get("effort") or "medium",
81
+ api_key=key, # reaches the dsh process only; its shell tool does not inherit it
82
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": str(home), "LANG": "C.UTF-8"},
83
+ ) as harness:
84
+ prompt = ctx.prompt + SUMMARY_RULE
85
+ run = harness.run(prompt, session_id=ctx.resume) if ctx.resume else harness.run(prompt)
86
+ with (ctx.folder / f"output-{ctx.attempt}.jsonl").open("a") as log:
87
+ for event in run.events:
88
+ if event.get("type") in ("assistant/message", "turn/end"):
89
+ log.write(json.dumps(event)[:4000] + "\n")
90
+ return Outcome(
91
+ ok=run.finish_reason == "completed" and bool(run.final_response),
92
+ subtype=run.finish_reason or "error",
93
+ result=run.final_response or "",
94
+ session_id=run.session_id,
95
+ cost_usd=dsh_cost(run.events),
96
+ )
@@ -0,0 +1,75 @@
1
+ """Kimi Code CLI (`kimi -p --output-format stream-json`). Tested with 2.1.1.
2
+
3
+ Kimi 2.1 refuses `--prompt` together with `--auto` ("Cannot combine --prompt
4
+ with --auto"); prompt mode already runs tools without asking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ from .base import Capabilities, CliAdapter, RunContext, describe_command, json_line, short_command
13
+
14
+
15
+ class Kimi(CliAdapter):
16
+ name = "kimi"
17
+ family = "kimi"
18
+ binary = "kimi"
19
+ supported = "kimi >=2.1"
20
+ capabilities = Capabilities(
21
+ readonly=False, # --plan is a mode, not an enforced read-only boundary
22
+ levels=("worktree", "isolated"),
23
+ command_network_off=False,
24
+ cost="none",
25
+ resume=True,
26
+ structured_events=True,
27
+ paid=False,
28
+ notes="no engine sandbox: use level=isolated for a filesystem boundary",
29
+ )
30
+
31
+ def help_args(self) -> list[str]:
32
+ return ["--help"]
33
+
34
+ def required_flags(self) -> list[str]:
35
+ return ["--output-format", "--prompt", "--session"]
36
+
37
+ def auth_state(self) -> str:
38
+ return "present" if (Path.home() / ".kimi").is_dir() else "unknown"
39
+
40
+ def argv(self, ctx: RunContext) -> list[str]:
41
+ argv = ["kimi", "--output-format", "stream-json"]
42
+ if ctx.spec.get("model"):
43
+ argv += ["-m", ctx.spec["model"]]
44
+ if ctx.resume:
45
+ argv += ["-S", ctx.resume] if ctx.resume != "continue" else ["-c"]
46
+ return argv + ["-p", ctx.prompt]
47
+
48
+ def parse(self, line: str, state: dict) -> str | None:
49
+ event = json_line(line)
50
+ if not event:
51
+ return None
52
+ role = event.get("role")
53
+ if role == "meta" and event.get("type") == "session.resume_hint":
54
+ state["session"] = event.get("session_id")
55
+ elif role == "assistant":
56
+ if event.get("content"):
57
+ content = event["content"]
58
+ state["text"].append(content if isinstance(content, str) else json.dumps(content))
59
+ for call in event.get("tool_calls") or []:
60
+ fn = call.get("function") or {}
61
+ name = fn.get("name", "tool")
62
+ try:
63
+ args = json.loads(fn.get("arguments") or "{}")
64
+ except json.JSONDecodeError:
65
+ args = {}
66
+ state.setdefault("pending", {})[call.get("id")] = args.get("command")
67
+ if args.get("command"):
68
+ return describe_command(short_command(args["command"]))
69
+ target = args.get("path") or args.get("file_path") or args.get("pattern") or ""
70
+ return f"{name} {Path(str(target)).name}".strip()
71
+ elif role == "tool":
72
+ command = state.get("pending", {}).pop(event.get("tool_call_id"), None)
73
+ if command:
74
+ state["commands"].append({"command": short_command(command), "exit_code": None})
75
+ return None
@@ -0,0 +1,87 @@
1
+ """OpenCode CLI (`opencode run --format json`). Tested with v2.0.28."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .base import Capabilities, CliAdapter, RunContext, describe_command, json_line, short_command
8
+
9
+
10
+ class OpenCode(CliAdapter):
11
+ name = "opencode"
12
+ family = "opencode"
13
+ binary = "opencode"
14
+ supported = "opencode >=2.0"
15
+ extra: tuple[str, ...] = ()
16
+ capabilities = Capabilities(
17
+ readonly=False,
18
+ levels=("worktree", "isolated"),
19
+ command_network_off=False,
20
+ cost="usd",
21
+ resume=True,
22
+ structured_events=True,
23
+ paid=False,
24
+ notes="cost is what OpenCode reports per step; billed by the provider, not the subtask cap",
25
+ )
26
+
27
+ def help_args(self) -> list[str]:
28
+ return ["run", "--help"]
29
+
30
+ def required_flags(self) -> list[str]:
31
+ return ["--format", "--auto", "--session", "--model"]
32
+
33
+ def auth_state(self) -> str:
34
+ auth = Path.home() / ".local" / "share" / "opencode" / "auth.json"
35
+ return "present" if auth.is_file() else "unknown"
36
+
37
+ def argv(self, ctx: RunContext) -> list[str]:
38
+ argv = ["opencode", "run", "--format", "json", "--auto", *self.extra]
39
+ model = ctx.spec.get("model") or self.model
40
+ if model:
41
+ argv += ["--model", model]
42
+ if ctx.resume:
43
+ argv += ["--session", ctx.resume] if ctx.resume != "continue" else ["--continue"]
44
+ return argv + ["--", ctx.prompt]
45
+
46
+ def parse(self, line: str, state: dict) -> str | None:
47
+ event = json_line(line)
48
+ if not event:
49
+ return None
50
+ state["session"] = event.get("sessionID") or state.get("session")
51
+ part = event.get("part") or {}
52
+ kind = event.get("type")
53
+ if kind == "tool_use":
54
+ tool = part.get("tool", "tool")
55
+ st = part.get("state") or {}
56
+ args = st.get("input") or {}
57
+ if args.get("command"):
58
+ cmd = short_command(args["command"])
59
+ if st.get("status") == "completed":
60
+ exit_code = (st.get("metadata") or {}).get("exit")
61
+ state["commands"].append({"command": cmd, "exit_code": exit_code})
62
+ return describe_command(cmd)
63
+ target = args.get("filePath") or args.get("path") or args.get("pattern") or ""
64
+ return f"{tool} {Path(str(target)).name}".strip()
65
+ if kind == "text":
66
+ state["text"].append(part.get("text") or "")
67
+ elif kind == "step_finish":
68
+ if isinstance(part.get("cost"), (int, float)):
69
+ state["cost_reported"] = state.get("cost_reported", 0.0) + part["cost"]
70
+ for key, value in (part.get("tokens") or {}).items():
71
+ if isinstance(value, int):
72
+ state["tokens"][key] = state["tokens"].get(key, 0) + value
73
+ elif kind == "error":
74
+ err = event.get("error") or {}
75
+ state["error"] = str(err.get("message") or err)[:500]
76
+ if "quota" in str(err.get("type", "")) or err.get("status") == 402:
77
+ state["quota"] = True
78
+ state["error_kind"] = "quota exhausted"
79
+ return None
80
+
81
+
82
+ class OpenCodeGo(OpenCode):
83
+ """OpenCode on the opencode-go subscription: fixed model, private server per job."""
84
+
85
+ name = "opencode-go"
86
+ model = "opencode-go/deepseek-v4.1-flash#max"
87
+ extra = ("--standalone",)
@@ -0,0 +1,124 @@
1
+ """Engine registry: built-ins plus plugins, without touching the core.
2
+
3
+ Plugins: a package declares `[project.entry-points."subtask.engines"] name = "module:factory"`,
4
+ or the environment lists `SUBTASK_ENGINE_PLUGINS=module:factory,...` (useful for tests).
5
+ A factory returns an Adapter instance. Plugins with a different ADAPTER_API are refused.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import os
12
+ from functools import cache
13
+
14
+ from .base import ADAPTER_API, Adapter
15
+
16
+
17
+ def _builtins() -> dict[str, Adapter]:
18
+ from .claude_api import ClaudeApi
19
+ from .codex import Codex
20
+ from .dsh import Dsh
21
+ from .kimi import Kimi
22
+ from .opencode import OpenCode, OpenCodeGo
23
+
24
+ engines: list[Adapter] = [
25
+ ClaudeApi("sonnet-api", "sonnet"),
26
+ ClaudeApi("haiku-api", "haiku"),
27
+ ClaudeApi("opus-api", "opus"),
28
+ Codex(),
29
+ Kimi(),
30
+ OpenCodeGo(),
31
+ OpenCode(),
32
+ Dsh(),
33
+ ]
34
+ if os.environ.get("SUBTASK_DEMO") == "1":
35
+ from .demo import Demo
36
+
37
+ engines.append(Demo())
38
+ return {e.name: e for e in engines}
39
+
40
+
41
+ def _load(spec: str) -> Adapter:
42
+ module, _, attr = spec.partition(":")
43
+ factory = getattr(importlib.import_module(module), attr or "adapter")
44
+ adapter = factory() if callable(factory) and not isinstance(factory, Adapter) else factory
45
+ if not isinstance(adapter, Adapter):
46
+ raise TypeError(f"{spec} did not return an Adapter")
47
+ if getattr(adapter, "api", ADAPTER_API) != ADAPTER_API:
48
+ raise TypeError(f"{spec} targets adapter API {adapter.api}, this is {ADAPTER_API}")
49
+ return adapter
50
+
51
+
52
+ GROUP = "subtask.engines"
53
+
54
+
55
+ def _entry_points() -> list[str]:
56
+ """`subtask.engines` entry points, read straight from dist-info files.
57
+
58
+ importlib.metadata costs ~20 ms to import; this scan costs ~1 ms and reads
59
+ the same entry_points.txt files.
60
+ """
61
+ import sys
62
+ from pathlib import Path
63
+
64
+ found: list[str] = []
65
+ for root in sys.path:
66
+ try:
67
+ files = list(Path(root or ".").glob("*.dist-info/entry_points.txt"))
68
+ except OSError:
69
+ continue
70
+ for path in files:
71
+ try:
72
+ text = path.read_text(encoding="utf-8")
73
+ except OSError:
74
+ continue
75
+ if f"[{GROUP}]" not in text:
76
+ continue
77
+ section = text.split(f"[{GROUP}]", 1)[1].split("\n[", 1)[0]
78
+ for line in section.splitlines():
79
+ name, sep, value = line.partition("=")
80
+ if sep and name.strip() and not line.lstrip().startswith(("#", ";")):
81
+ found.append(value.strip())
82
+ return found
83
+
84
+
85
+ @cache
86
+ def _all() -> tuple[dict[str, Adapter], tuple[str, ...]]:
87
+ engines = _builtins()
88
+ errors: list[str] = []
89
+ specs = [s for s in os.environ.get("SUBTASK_ENGINE_PLUGINS", "").split(",") if s.strip()]
90
+ try:
91
+ specs += _entry_points()
92
+ except Exception as exc: # noqa: BLE001 - broken metadata must not break the built-ins
93
+ errors.append(f"entry points unreadable: {type(exc).__name__}: {exc}")
94
+ for spec in specs:
95
+ try:
96
+ adapter = _load(spec.strip())
97
+ except Exception as exc: # noqa: BLE001
98
+ errors.append(f"{spec}: {type(exc).__name__}: {exc}")
99
+ continue
100
+ if adapter.name in engines:
101
+ errors.append(f"{spec}: name {adapter.name!r} already taken")
102
+ continue
103
+ engines[adapter.name] = adapter
104
+ return engines, tuple(errors)
105
+
106
+
107
+ def all_engines() -> dict[str, Adapter]:
108
+ return _all()[0]
109
+
110
+
111
+ def plugin_errors() -> tuple[str, ...]:
112
+ return _all()[1]
113
+
114
+
115
+ def get(name: str) -> Adapter | None:
116
+ return all_engines().get(name)
117
+
118
+
119
+ def names() -> list[str]:
120
+ return list(all_engines())
121
+
122
+
123
+ def reset() -> None:
124
+ _all.cache_clear()