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,355 @@
1
+ """Where each client keeps its MCP entry and skills, and how to read and change it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import tomllib
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import tomledit
16
+ from .fileops import atomic_write, read_bytes
17
+
18
+ NAME = "subtask"
19
+ CODEX_TOOL_TIMEOUT_SEC = 660
20
+ KIMI_TOOL_TIMEOUT_MS = 660_000
21
+ NATIVE_TIMEOUT_S = 60
22
+
23
+
24
+ class InstallError(RuntimeError):
25
+ """A step failed; the message is safe to show."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Step:
30
+ """One planned change: a native command (argv) or a file edit (description)."""
31
+
32
+ kind: str # "run" | "edit" (set a key) | "unset" (remove a key)
33
+ target: str
34
+ argv: tuple[str, ...] = ()
35
+
36
+ def describe(self) -> str:
37
+ if self.kind == "run":
38
+ return "run: " + " ".join(_quote(a) for a in self.argv)
39
+ return f"{self.kind}: {self.target}"
40
+
41
+
42
+ def _quote(arg: str) -> str:
43
+ import shlex
44
+
45
+ return shlex.quote(arg)
46
+
47
+
48
+ def run_native(argv: list[str]) -> None:
49
+ try:
50
+ proc = subprocess.run(
51
+ argv,
52
+ stdin=subprocess.DEVNULL,
53
+ check=False,
54
+ capture_output=True,
55
+ text=True,
56
+ timeout=NATIVE_TIMEOUT_S,
57
+ )
58
+ except (OSError, subprocess.TimeoutExpired) as exc:
59
+ raise InstallError(f"{argv[0]} failed: {exc}") from exc
60
+ if proc.returncode != 0:
61
+ tail = (proc.stderr or proc.stdout).strip().splitlines()[-3:]
62
+ raise InstallError(f"{' '.join(argv[:3])} exited {proc.returncode}: {' | '.join(tail)}")
63
+
64
+
65
+ def strip_jsonc(text: str) -> str:
66
+ """JSON with comments and trailing commas (OpenCode's opencode.jsonc) -> plain JSON text."""
67
+ out, i, n, in_str = [], 0, len(text), False
68
+ while i < n:
69
+ ch = text[i]
70
+ if in_str:
71
+ out.append(ch)
72
+ if ch == "\\":
73
+ out.append(text[i + 1 : i + 2])
74
+ i += 2
75
+ continue
76
+ if ch == '"':
77
+ in_str = False
78
+ elif ch == '"':
79
+ in_str = True
80
+ out.append(ch)
81
+ elif text.startswith("//", i):
82
+ while i < n and text[i] != "\n":
83
+ i += 1
84
+ continue
85
+ elif text.startswith("/*", i):
86
+ end = text.find("*/", i + 2)
87
+ i = n if end < 0 else end + 2
88
+ continue
89
+ else:
90
+ out.append(ch)
91
+ i += 1
92
+ return re.sub(r",(\s*[}\]])", r"\1", "".join(out))
93
+
94
+
95
+ def _has_comments(raw: bytes) -> bool:
96
+ text = raw.decode("utf-8", errors="replace")
97
+ return strip_jsonc(text) != re.sub(r",(\s*[}\]])", r"\1", text)
98
+
99
+
100
+ def _load_json(path: Path) -> dict[str, Any]:
101
+ raw = read_bytes(path)
102
+ if raw is None or not raw.strip():
103
+ return {}
104
+ try:
105
+ data = json.loads(strip_jsonc(raw.decode("utf-8")))
106
+ except (ValueError, UnicodeDecodeError) as exc:
107
+ raise InstallError(f"{path} is not plain JSON ({exc}); edit it by hand") from exc
108
+ if not isinstance(data, dict):
109
+ raise InstallError(f"{path}: top level is not an object")
110
+ return data
111
+
112
+
113
+ def _dig(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
114
+ for k in keys:
115
+ if not isinstance(data, dict):
116
+ return None
117
+ data = data.get(k) # type: ignore[assignment]
118
+ return data
119
+
120
+
121
+ def json_set(path: Path, keys: tuple[str, ...], value: Any | None) -> None:
122
+ """Set (or with None remove) one nested key; everything else is kept as parsed.
123
+
124
+ A file with comments is never rewritten (the comments would be lost): edit it by hand.
125
+ """
126
+ raw = read_bytes(path)
127
+ data = _load_json(path)
128
+ if value is None and _dig(data, keys) is None:
129
+ return # nothing to remove: the file is not touched
130
+ if raw is not None and _has_comments(raw):
131
+ raise InstallError(
132
+ f"{path} has comments; rewriting it would drop them: change {'.'.join(keys)} by hand"
133
+ )
134
+ node = data
135
+ for k in keys[:-1]:
136
+ child = node.get(k)
137
+ if child is None:
138
+ if value is None:
139
+ return
140
+ child = {}
141
+ if not isinstance(child, dict):
142
+ raise InstallError(f"{path}: '{k}' is not an object")
143
+ node[k] = dict(child)
144
+ node = node[k]
145
+ if value is None:
146
+ node.pop(keys[-1], None)
147
+ else:
148
+ node[keys[-1]] = value
149
+ atomic_write(path, (json.dumps(data, indent=2) + "\n").encode())
150
+
151
+
152
+ class Client:
153
+ name = ""
154
+ binary = ""
155
+ scopes: tuple[str, ...] = ("user", "project")
156
+
157
+ def __init__(self, cwd: Path | None = None) -> None:
158
+ self.cwd = cwd or Path.cwd()
159
+
160
+ # --- detection
161
+ def exe(self) -> str | None:
162
+ return shutil.which(self.binary)
163
+
164
+ def version(self) -> str | None:
165
+ exe = self.exe()
166
+ if not exe:
167
+ return None
168
+ try:
169
+ out = subprocess.run(
170
+ [exe, "--version"],
171
+ stdin=subprocess.DEVNULL,
172
+ check=False,
173
+ capture_output=True,
174
+ text=True,
175
+ timeout=20,
176
+ )
177
+ except (OSError, subprocess.TimeoutExpired):
178
+ return "unknown"
179
+ lines = (out.stdout or out.stderr).strip().splitlines()
180
+ return lines[0][:80] if lines else "unknown"
181
+
182
+ def check_scope(self, scope: str) -> None:
183
+ if scope not in self.scopes:
184
+ raise InstallError(f"{self.name} does not support --scope {scope}")
185
+
186
+ # --- per client
187
+ def config_path(self, scope: str) -> Path:
188
+ raise NotImplementedError
189
+
190
+ def read_entry(self, scope: str) -> dict[str, Any] | None:
191
+ raise NotImplementedError
192
+
193
+ def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
194
+ raise NotImplementedError
195
+
196
+ def remove_steps(self, scope: str) -> list[Step]:
197
+ raise NotImplementedError
198
+
199
+ def apply(self, step: Step, scope: str, argv: list[str]) -> None:
200
+ if step.kind == "run":
201
+ run_native(list(step.argv))
202
+ else:
203
+ raise InstallError(f"unknown step {step.describe()}")
204
+
205
+ def skill_path(self, scope: str) -> Path:
206
+ raise NotImplementedError
207
+
208
+ @staticmethod
209
+ def target(entry: dict[str, Any] | None) -> list[str] | None:
210
+ """The launched argv of an entry, comparable across clients."""
211
+ if not entry:
212
+ return None
213
+ cmd = entry.get("command")
214
+ if isinstance(cmd, list):
215
+ return [str(c) for c in cmd]
216
+ return [str(cmd), *[str(a) for a in entry.get("args") or []]]
217
+
218
+
219
+ class Claude(Client):
220
+ name = binary = "claude"
221
+
222
+ def config_path(self, scope: str) -> Path:
223
+ return Path.home() / ".claude.json" if scope == "user" else self.cwd / ".mcp.json"
224
+
225
+ def read_entry(self, scope: str) -> dict[str, Any] | None:
226
+ return _dig(_load_json(self.config_path(scope)), ("mcpServers", NAME))
227
+
228
+ def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
229
+ cmd = (self.exe() or "claude", "mcp", "add", "-s", scope, NAME, "--", *argv)
230
+ return [Step("run", str(self.config_path(scope)), cmd)]
231
+
232
+ def remove_steps(self, scope: str) -> list[Step]:
233
+ cmd = (self.exe() or "claude", "mcp", "remove", "-s", scope, NAME)
234
+ return [Step("run", str(self.config_path(scope)), cmd)]
235
+
236
+ def skill_path(self, scope: str) -> Path:
237
+ base = Path.home() if scope == "user" else self.cwd
238
+ return base / ".claude" / "skills" / NAME / "SKILL.md"
239
+
240
+
241
+ class Codex(Client):
242
+ name = binary = "codex"
243
+ scopes = ("user",)
244
+
245
+ def home(self) -> Path:
246
+ return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
247
+
248
+ def config_path(self, scope: str) -> Path:
249
+ return self.home() / "config.toml"
250
+
251
+ def read_entry(self, scope: str) -> dict[str, Any] | None:
252
+ raw = read_bytes(self.config_path(scope))
253
+ if not raw:
254
+ return None
255
+ try:
256
+ data = tomllib.loads(raw.decode())
257
+ except (ValueError, UnicodeDecodeError) as exc:
258
+ raise InstallError(f"{self.config_path(scope)} is not valid TOML: {exc}") from exc
259
+ return _dig(data, ("mcp_servers", NAME))
260
+
261
+ def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
262
+ path = str(self.config_path(scope))
263
+ return [
264
+ Step("run", path, (self.exe() or "codex", "mcp", "add", NAME, "--", *argv)),
265
+ Step("edit", f"{path}: [mcp_servers.{NAME}] tool_timeout_sec = {CODEX_TOOL_TIMEOUT_SEC}"),
266
+ ]
267
+
268
+ def remove_steps(self, scope: str) -> list[Step]:
269
+ path = str(self.config_path(scope))
270
+ return [Step("run", path, (self.exe() or "codex", "mcp", "remove", NAME))]
271
+
272
+ def apply(self, step: Step, scope: str, argv: list[str]) -> None:
273
+ if step.kind != "edit":
274
+ return super().apply(step, scope, argv)
275
+ path = self.config_path(scope)
276
+ text = (read_bytes(path) or b"").decode()
277
+ try:
278
+ new = tomledit.set_key(
279
+ text, f"mcp_servers.{NAME}", "tool_timeout_sec", str(CODEX_TOOL_TIMEOUT_SEC)
280
+ )
281
+ except ValueError as exc:
282
+ raise InstallError(f"{path}: {exc}") from exc
283
+ atomic_write(path, new.encode())
284
+
285
+ def skill_path(self, scope: str) -> Path:
286
+ return Path.home() / ".agents" / "skills" / NAME / "SKILL.md"
287
+
288
+
289
+ class Kimi(Client):
290
+ name = binary = "kimi"
291
+
292
+ def home(self) -> Path:
293
+ return Path(os.environ.get("KIMI_CODE_HOME") or Path.home() / ".kimi-code")
294
+
295
+ def config_path(self, scope: str) -> Path:
296
+ return self.home() / "mcp.json" if scope == "user" else self.cwd / ".kimi-code" / "mcp.json"
297
+
298
+ def read_entry(self, scope: str) -> dict[str, Any] | None:
299
+ return _dig(_load_json(self.config_path(scope)), ("mcpServers", NAME))
300
+
301
+ def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
302
+ return [
303
+ Step("edit", f"{self.config_path(scope)}: mcpServers.{NAME} = {json.dumps(self.entry(argv))}")
304
+ ]
305
+
306
+ def remove_steps(self, scope: str) -> list[Step]:
307
+ return [Step("unset", f"{self.config_path(scope)}: mcpServers.{NAME}")]
308
+
309
+ @staticmethod
310
+ def entry(argv: list[str]) -> dict[str, Any]:
311
+ return {"command": argv[0], "args": argv[1:], "toolTimeoutMs": KIMI_TOOL_TIMEOUT_MS}
312
+
313
+ def apply(self, step: Step, scope: str, argv: list[str]) -> None:
314
+ value = None if step.kind == "unset" else self.entry(argv)
315
+ json_set(self.config_path(scope), ("mcpServers", NAME), value)
316
+
317
+ def skill_path(self, scope: str) -> Path:
318
+ base = self.home() if scope == "user" else self.cwd / ".kimi-code"
319
+ return base / "skills" / NAME / "SKILL.md"
320
+
321
+
322
+ class OpenCode(Client):
323
+ name = binary = "opencode"
324
+
325
+ def config_path(self, scope: str) -> Path:
326
+ if scope == "project":
327
+ return self.cwd / "opencode.json"
328
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "opencode"
329
+ if not (base / "opencode.json").exists() and (base / "opencode.jsonc").exists():
330
+ return base / "opencode.jsonc" # what `opencode mcp add --global` writes by default
331
+ return base / "opencode.json"
332
+
333
+ def read_entry(self, scope: str) -> dict[str, Any] | None:
334
+ return _dig(_load_json(self.config_path(scope)), ("mcp", "servers", NAME))
335
+
336
+ def add_steps(self, scope: str, argv: list[str]) -> list[Step]:
337
+ flags = ("--global",) if scope == "user" else ()
338
+ cmd = (self.exe() or "opencode", "mcp", "add", NAME, *flags, "--", *argv)
339
+ return [Step("run", str(self.config_path(scope)), cmd)]
340
+
341
+ def remove_steps(self, scope: str) -> list[Step]: # opencode has no `mcp remove`
342
+ return [Step("unset", f"{self.config_path(scope)}: mcp.servers.{NAME}")]
343
+
344
+ def apply(self, step: Step, scope: str, argv: list[str]) -> None:
345
+ if step.kind == "run":
346
+ return super().apply(step, scope, argv)
347
+ json_set(self.config_path(scope), ("mcp", "servers", NAME), None)
348
+
349
+ def skill_path(self, scope: str) -> Path:
350
+ if scope == "project":
351
+ return self.cwd / ".opencode" / "skills" / NAME / "SKILL.md"
352
+ return self.config_path("user").parent / "skills" / NAME / "SKILL.md"
353
+
354
+
355
+ CLIENTS: dict[str, type[Client]] = {"claude": Claude, "codex": Codex, "kimi": Kimi, "opencode": OpenCode}
@@ -0,0 +1,122 @@
1
+ """`subtask install | uninstall | doctor` argument parsing and output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .clients import CLIENTS, Client, InstallError
12
+ from .doctor import doctor
13
+ from .installer import install, uninstall
14
+
15
+ CHOICES = [*CLIENTS, "all"]
16
+
17
+
18
+ def add_parsers(sub: argparse._SubParsersAction) -> None:
19
+ i = sub.add_parser(
20
+ "install", help="register the MCP server and skill in a client (plan only without --yes)"
21
+ )
22
+ u = sub.add_parser("uninstall", help="remove what `subtask install` added (plan only without --yes)")
23
+ d = sub.add_parser("doctor", help="check a client: app, config entry, server handshake")
24
+ for p in (i, u, d):
25
+ p.add_argument(
26
+ "--client", required=p is not d, choices=CHOICES, help="doctor without --client checks engines"
27
+ )
28
+ p.add_argument("--scope", default="user", choices=["user", "project"])
29
+ p.add_argument("--json", action="store_true", help="machine-readable output on stdout")
30
+ for p in (i, u):
31
+ p.add_argument(
32
+ "--yes", action="store_true", help="apply the plan (default: print it, change nothing)"
33
+ )
34
+ p.add_argument(
35
+ "--dry-run", action="store_true", help="print the plan only (the default without --yes)"
36
+ )
37
+ i.add_argument(
38
+ "--force", action="store_true", help="replace an existing 'subtask' entry that is not ours"
39
+ )
40
+ i.add_argument("--command", help="server command to register (default: subtask-mcp next to this python)")
41
+
42
+
43
+ def server_command(override: str | None) -> list[str]:
44
+ if override:
45
+ return [override]
46
+ here = Path(sys.executable).parent / "subtask-mcp"
47
+ if here.is_file():
48
+ return [str(here.absolute())]
49
+ import shutil
50
+
51
+ found = shutil.which("subtask-mcp")
52
+ if not found:
53
+ raise InstallError("subtask-mcp not found next to this python nor on PATH; pass --command PATH")
54
+ return [str(Path(found).absolute())]
55
+
56
+
57
+ def _selected(name: str) -> tuple[list[Client], list[str]]:
58
+ if name != "all":
59
+ return [CLIENTS[name]()], []
60
+ clients = [cls() for cls in CLIENTS.values()]
61
+ return [c for c in clients if c.exe()], [c.name for c in clients if not c.exe()]
62
+
63
+
64
+ def run(a: argparse.Namespace) -> int:
65
+ clients, missing = _selected(a.client)
66
+ if a.cmd == "doctor":
67
+ reports = [doctor(c, a.scope) for c in clients]
68
+ ok = all(r.get("connects") for r in reports) and bool(reports)
69
+ _emit(a.json, {"action": "doctor", "clients": reports, "not_detected": missing}, _doctor_text)
70
+ return 0 if ok else 1
71
+ apply = a.yes and not a.dry_run
72
+ try:
73
+ argv = server_command(a.command) if a.cmd == "install" else []
74
+ except InstallError as exc:
75
+ _emit(a.json, {"action": a.cmd, "error": str(exc)}, lambda d: f"error: {d['error']}")
76
+ return 2
77
+ results = []
78
+ for c in clients:
79
+ if a.cmd == "install" and not c.exe():
80
+ results.append({"client": c.name, "scope": a.scope, "status": "skipped",
81
+ "steps": [], "notes": [f"{c.binary} not found on PATH; install the app first"]}) # fmt: skip
82
+ continue
83
+ out = (
84
+ install(c, a.scope, argv, apply, a.force) if a.cmd == "install" else uninstall(c, a.scope, apply)
85
+ )
86
+ results.append(out.as_dict())
87
+ data = {"action": a.cmd, "applied": apply, "command": argv, "clients": results, "not_detected": missing}
88
+ _emit(a.json, data, _plan_text)
89
+ bad = any(r["status"] in ("error", "collision") for r in results)
90
+ return 1 if bad else 0
91
+
92
+
93
+ def _emit(as_json: bool, data: dict[str, Any], render) -> None:
94
+ print(json.dumps(data, indent=2) if as_json else render(data))
95
+
96
+
97
+ def _plan_text(d: dict[str, Any]) -> str:
98
+ lines = [] if d["applied"] else ["Plan only. Nothing changed. Run again with --yes to apply."]
99
+ if d.get("command"):
100
+ lines.append("server command: " + " ".join(d["command"]))
101
+ for r in d["clients"]:
102
+ lines.append(f"[{r['client']}] {r['status']}")
103
+ lines += [f" {s}" for s in r["steps"]] + [f" note: {n}" for n in r["notes"]]
104
+ if d["not_detected"]:
105
+ lines.append("not detected (skipped): " + ", ".join(d["not_detected"]))
106
+ return "\n".join(lines)
107
+
108
+
109
+ def _doctor_text(d: dict[str, Any]) -> str:
110
+ lines = []
111
+ for r in d["clients"]:
112
+ hs = r.get("handshake") or {}
113
+ lines.append(f"[{r['client']}] app: {'yes ' + str(r['version']) if r['present'] else 'not found'}")
114
+ lines.append(f" config: {r.get('config')} configured: {'yes' if r.get('configured') else 'no'}")
115
+ if r.get("command"):
116
+ lines.append(f" command: {' '.join(r['command'])} exists: {r.get('command_exists')}")
117
+ conn = f"yes ({hs.get('tools')} tools, {hs.get('protocol')})" if r.get("connects") else \
118
+ f"no ({hs.get('error') or r.get('error') or 'not configured'})" # fmt: skip
119
+ lines.append(f" connects: {conn} auth: {r.get('auth', 'not needed')}")
120
+ if d["not_detected"]:
121
+ lines.append("not detected: " + ", ".join(d["not_detected"]))
122
+ return "\n".join(lines)
@@ -0,0 +1,112 @@
1
+ """Client-side health check: app present, entry configured, server answers over stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import selectors
8
+ import shutil
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .clients import Client, InstallError
15
+
16
+ HANDSHAKE_TIMEOUT_S = 10.0
17
+ PROTOCOL = "2025-06-18"
18
+
19
+
20
+ def _read_reply(proc: subprocess.Popen[bytes], want_id: int, deadline: float) -> dict[str, Any]:
21
+ assert proc.stdout is not None
22
+ sel = selectors.DefaultSelector()
23
+ sel.register(proc.stdout, selectors.EVENT_READ)
24
+ buf = b""
25
+ while True:
26
+ left = deadline - time.monotonic()
27
+ if left <= 0:
28
+ raise TimeoutError("no reply within the time limit")
29
+ if not sel.select(left):
30
+ continue
31
+ chunk = os.read(proc.stdout.fileno(), 65536)
32
+ if not chunk:
33
+ raise ConnectionError(f"server exited (code {proc.poll()})")
34
+ buf += chunk
35
+ while b"\n" in buf:
36
+ line, buf = buf.split(b"\n", 1)
37
+ try:
38
+ msg = json.loads(line)
39
+ except ValueError:
40
+ continue
41
+ if isinstance(msg, dict) and msg.get("id") == want_id:
42
+ return msg
43
+
44
+
45
+ def handshake(
46
+ argv: list[str],
47
+ env: dict[str, str] | None = None,
48
+ timeout: float = HANDSHAKE_TIMEOUT_S,
49
+ cwd: str | None = None,
50
+ ) -> dict:
51
+ """Spawn the server, send initialize + tools/list, return server name and tool count."""
52
+ deadline = time.monotonic() + timeout
53
+ full_env = {**os.environ, **(env or {})}
54
+ try:
55
+ proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
56
+ stderr=subprocess.DEVNULL, env=full_env, cwd=cwd) # fmt: skip
57
+ except OSError as exc:
58
+ return {"ok": False, "error": f"cannot start: {exc}"}
59
+ assert proc.stdin is not None
60
+ try:
61
+ init = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
62
+ "protocolVersion": PROTOCOL, "capabilities": {},
63
+ "clientInfo": {"name": "subtask-doctor", "version": "1"}}} # fmt: skip
64
+ proc.stdin.write((json.dumps(init) + "\n").encode())
65
+ proc.stdin.flush()
66
+ r1 = _read_reply(proc, 1, deadline)
67
+ if "error" in r1:
68
+ return {"ok": False, "error": f"initialize: {r1['error'].get('message')}"}
69
+ notes = {"jsonrpc": "2.0", "method": "notifications/initialized"}
70
+ listing = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
71
+ proc.stdin.write((json.dumps(notes) + "\n" + json.dumps(listing) + "\n").encode())
72
+ proc.stdin.flush()
73
+ r2 = _read_reply(proc, 2, deadline)
74
+ tools = (r2.get("result") or {}).get("tools") or []
75
+ info = (r1.get("result") or {}).get("serverInfo") or {}
76
+ return {"ok": bool(tools), "server": info.get("name"), "version": info.get("version"),
77
+ "protocol": (r1.get("result") or {}).get("protocolVersion"), "tools": len(tools)} # fmt: skip
78
+ except (TimeoutError, ConnectionError, OSError) as exc:
79
+ return {"ok": False, "error": str(exc)}
80
+ finally:
81
+ try:
82
+ proc.stdin.close()
83
+ except OSError:
84
+ pass # the server already died: nothing to flush
85
+ try:
86
+ proc.wait(timeout=3)
87
+ except subprocess.TimeoutExpired:
88
+ proc.kill()
89
+ proc.wait()
90
+
91
+
92
+ def doctor(c: Client, scope: str) -> dict[str, Any]:
93
+ exe = c.exe()
94
+ report: dict[str, Any] = {"client": c.name, "scope": scope, "present": bool(exe), "binary": exe,
95
+ "version": c.version() if exe else None} # fmt: skip
96
+ try:
97
+ c.check_scope(scope)
98
+ path = c.config_path(scope)
99
+ entry = c.read_entry(scope)
100
+ except InstallError as exc:
101
+ return {**report, "configured": False, "connects": False, "error": str(exc)}
102
+ argv = Client.target(entry)
103
+ report |= {"config": str(path), "configured": bool(argv), "command": argv}
104
+ if not argv:
105
+ return {**report, "connects": False, "auth": "not needed"}
106
+ launcher = argv[0] if os.path.isabs(argv[0]) else shutil.which(argv[0])
107
+ report["command_exists"] = bool(launcher and Path(launcher).is_file() and os.access(launcher, os.X_OK))
108
+ env = (entry or {}).get("env") or {}
109
+ hs = handshake(argv, {str(k): str(v) for k, v in env.items()}) if report["command_exists"] else {
110
+ "ok": False, "error": "command not found or not executable"} # fmt: skip
111
+ # The subtask server needs no credentials to connect; engines read theirs only when a job runs.
112
+ return {**report, "connects": hs.pop("ok"), "handshake": hs, "auth": "not needed"}
@@ -0,0 +1,69 @@
1
+ """Safe file edits: backup, atomic write that keeps the mode, restore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import tempfile
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ def sha256(data: bytes) -> str:
14
+ return hashlib.sha256(data).hexdigest()
15
+
16
+
17
+ def read_bytes(path: Path) -> bytes | None:
18
+ try:
19
+ return path.read_bytes()
20
+ except FileNotFoundError:
21
+ return None
22
+
23
+
24
+ def atomic_write(path: Path, data: bytes) -> None:
25
+ """Write via a temp file in the same dir, then os.replace; keep the old mode."""
26
+ path.parent.mkdir(parents=True, exist_ok=True)
27
+ try:
28
+ mode: int | None = path.stat().st_mode & 0o7777
29
+ except FileNotFoundError:
30
+ mode = None
31
+ fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
32
+ try:
33
+ with os.fdopen(fd, "wb") as fh:
34
+ fh.write(data)
35
+ fh.flush()
36
+ os.fsync(fh.fileno())
37
+ if mode is not None:
38
+ os.chmod(tmp, mode)
39
+ os.replace(tmp, path)
40
+ except BaseException:
41
+ Path(tmp).unlink(missing_ok=True)
42
+ raise
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Snapshot:
47
+ """What a file held before we touched it; `backup` is the copy on disk, if any."""
48
+
49
+ path: Path
50
+ before: bytes | None
51
+ backup: Path | None
52
+
53
+ def restore(self) -> None:
54
+ if self.before is None:
55
+ self.path.unlink(missing_ok=True)
56
+ else:
57
+ atomic_write(self.path, self.before)
58
+
59
+
60
+ def snapshot(path: Path, keep_copy: bool = True) -> Snapshot:
61
+ """Remember the file; with keep_copy leave a `<file>.subtask-bak-<timestamp>` copy next to it."""
62
+ before = read_bytes(path)
63
+ backup = None
64
+ if before is not None and keep_copy:
65
+ stamp = time.strftime("%Y%m%d-%H%M%S") + f"-{time.time_ns() % 1_000_000:06d}"
66
+ backup = path.with_name(f"{path.name}.subtask-bak-{stamp}")
67
+ atomic_write(backup, before)
68
+ os.chmod(backup, path.stat().st_mode & 0o7777)
69
+ return Snapshot(path, before, backup)