subtask-mcp 0.2.3__tar.gz → 0.2.6__tar.gz

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 (66) hide show
  1. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/PKG-INFO +2 -2
  2. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/README.md +1 -1
  3. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/__init__.py +1 -1
  4. subtask_mcp-0.2.6/subtask/install/claude_plugin.py +134 -0
  5. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/commands.py +41 -4
  6. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/worker.py +7 -0
  7. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/PKG-INFO +2 -2
  8. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/SOURCES.txt +1 -0
  9. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_concurrency.py +24 -0
  10. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_install.py +53 -1
  11. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_routing_results.py +11 -4
  12. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/LICENSE +0 -0
  13. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/pyproject.toml +0 -0
  14. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/setup.cfg +0 -0
  15. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/admission.py +0 -0
  16. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/capacity.py +0 -0
  17. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/cli.py +0 -0
  18. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/config.py +0 -0
  19. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/core.py +0 -0
  20. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/doctor.py +0 -0
  21. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/__init__.py +0 -0
  22. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/base.py +0 -0
  23. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/claude_api.py +0 -0
  24. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/codex.py +0 -0
  25. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/demo.py +0 -0
  26. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/dsh.py +0 -0
  27. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/kimi.py +0 -0
  28. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/opencode.py +0 -0
  29. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/engines/registry.py +0 -0
  30. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/events.py +0 -0
  31. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/evidence.py +0 -0
  32. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/fastserver.py +0 -0
  33. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/gc.py +0 -0
  34. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/__init__.py +0 -0
  35. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/clients.py +0 -0
  36. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/doctor.py +0 -0
  37. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/fileops.py +0 -0
  38. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/installer.py +0 -0
  39. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/ownership.py +0 -0
  40. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/install/tomledit.py +0 -0
  41. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/jobs.py +0 -0
  42. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/ledger.py +0 -0
  43. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/narrate.py +0 -0
  44. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/policy.py +0 -0
  45. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/procid.py +0 -0
  46. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/progress.py +0 -0
  47. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/redact.py +0 -0
  48. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/roi.py +0 -0
  49. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/router.py +0 -0
  50. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/runners.py +0 -0
  51. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/schemacheck.py +0 -0
  52. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/server.py +0 -0
  53. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/skill/SKILL.md +0 -0
  54. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/telemetry.py +0 -0
  55. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask/tools.json +0 -0
  56. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/dependency_links.txt +0 -0
  57. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/entry_points.txt +0 -0
  58. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/requires.txt +0 -0
  59. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/subtask_mcp.egg-info/top_level.txt +0 -0
  60. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_engines.py +0 -0
  61. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_falsify.py +0 -0
  62. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_independent_client.py +0 -0
  63. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_protocol.py +0 -0
  64. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_schema.py +0 -0
  65. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_security.py +0 -0
  66. {subtask_mcp-0.2.3 → subtask_mcp-0.2.6}/tests/test_subtask.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/tedin7/subtask
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
70
70
  project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
71
71
 
72
72
  ```sh
73
- uv tool install "git+https://github.com/tedin7/subtask@v0.2.3" # or: uv tool install /path/to/checkout
73
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.6" # or: uv tool install /path/to/checkout
74
74
  subtask install --client all # prints the plan for every detected client
75
75
  subtask install --client all --yes # applies it: MCP server + skill
76
76
  subtask doctor --client all # checks app, config entry and a real handshake
@@ -39,7 +39,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
39
39
  project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
40
40
 
41
41
  ```sh
42
- uv tool install "git+https://github.com/tedin7/subtask@v0.2.3" # or: uv tool install /path/to/checkout
42
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.6" # or: uv tool install /path/to/checkout
43
43
  subtask install --client all # prints the plan for every detected client
44
44
  subtask install --client all --yes # applies it: MCP server + skill
45
45
  subtask doctor --client all # checks app, config entry and a real handshake
@@ -1,3 +1,3 @@
1
1
  """subtask: background sub-agents for coding agents."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.6"
@@ -0,0 +1,134 @@
1
+ """Claude Code through its plugin: one server, one skill, one hook, from one place.
2
+
3
+ The plugin (marketplace `subtask` in the tedin7/subtask repository) carries the
4
+ MCP server, the skill and the timing hook. Installing it this way replaces the
5
+ older route (a user-scope `claude mcp add` entry plus a copied skill), so the
6
+ server is never registered twice. Everything goes through `claude plugin ...`
7
+ commands; the only file subtask touches is its own skill copy, and only when
8
+ the ownership record says it is ours.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from . import ownership
17
+ from .clients import Claude, InstallError, run_native
18
+ from .fileops import read_bytes, sha256
19
+ from .installer import Outcome
20
+
21
+ MARKETPLACE = "subtask"
22
+ SOURCE = "tedin7/subtask"
23
+ PLUGIN = f"subtask@{MARKETPLACE}"
24
+ SERVER = "plugin:subtask:subtask"
25
+
26
+
27
+ def _out(argv: list[str], timeout: float = 60) -> str:
28
+ try:
29
+ done = subprocess.run(
30
+ argv, capture_output=True, text=True, timeout=timeout, check=False, stdin=subprocess.DEVNULL
31
+ )
32
+ except (OSError, subprocess.TimeoutExpired) as exc:
33
+ raise InstallError(f"{' '.join(argv[:3])}: {exc}") from exc
34
+ return done.stdout + done.stderr
35
+
36
+
37
+ def state(c: Claude) -> dict:
38
+ exe = c.exe() or "claude"
39
+ markets = _out([exe, "plugin", "marketplace", "list"])
40
+ plugins = _out([exe, "plugin", "list"])
41
+ return {
42
+ "marketplace": f"❯ {MARKETPLACE}" in markets
43
+ or f" {MARKETPLACE}\n" in markets
44
+ or MARKETPLACE in markets.split(),
45
+ "plugin": PLUGIN in plugins,
46
+ "legacy_entry": c.read_entry("user"),
47
+ "skill": c.skill_path("user"),
48
+ }
49
+
50
+
51
+ def _ours(path: Path, client: str = "claude") -> bool:
52
+ cur = read_bytes(path)
53
+ rec = ownership.get(ownership.key(client, "user", "skill", path))
54
+ return cur is not None and bool(rec) and rec.get("sha") == sha256(cur)
55
+
56
+
57
+ def install(c: Claude, apply: bool, force: bool, source: str = SOURCE) -> Outcome:
58
+ out = Outcome(client="claude", scope="user")
59
+ exe = c.exe() or "claude"
60
+ st = state(c)
61
+ steps: list[list[str]] = []
62
+ if st["marketplace"]:
63
+ steps.append([exe, "plugin", "marketplace", "update", MARKETPLACE])
64
+ else:
65
+ steps.append([exe, "plugin", "marketplace", "add", source])
66
+ steps.append([exe, "plugin", "update" if st["plugin"] else "install", PLUGIN])
67
+ legacy = st["legacy_entry"]
68
+ if legacy:
69
+ target = " ".join(Claude.target(legacy) or [])
70
+ if "subtask-mcp" in target or force:
71
+ steps.append([exe, "mcp", "remove", "-s", "user", "subtask"]) # the plugin now provides it
72
+ else:
73
+ out.status = "collision"
74
+ out.notes.append(
75
+ f"a user-scope 'subtask' entry runs {target!r}; use --force to replace it with the plugin"
76
+ )
77
+ return out
78
+ skill: Path = st["skill"]
79
+ remove_skill = skill.is_file() and (_ours(skill) or force)
80
+ if skill.is_file() and not remove_skill:
81
+ out.notes.append(f"{skill} is not ours: kept (the plugin also ships the skill)")
82
+ out.steps = [f"run: {' '.join(s)}" for s in steps] + (
83
+ [f"remove: {skill} (the plugin ships it)"] if remove_skill else []
84
+ )
85
+ if not apply:
86
+ return out
87
+ try:
88
+ for argv in steps:
89
+ run_native(argv)
90
+ if remove_skill:
91
+ skill.unlink()
92
+ try:
93
+ skill.parent.rmdir()
94
+ except OSError:
95
+ pass
96
+ ownership.drop(ownership.key("claude", "user", "skill", skill))
97
+ except InstallError as exc:
98
+ out.status = "error"
99
+ out.notes.append(str(exc))
100
+ return out
101
+ out.status = "done"
102
+ return out
103
+
104
+
105
+ def uninstall(c: Claude, apply: bool) -> Outcome:
106
+ out = Outcome(client="claude", scope="user")
107
+ exe = c.exe() or "claude"
108
+ if PLUGIN not in _out([exe, "plugin", "list"]):
109
+ out.status = "unchanged"
110
+ out.notes.append("plugin not installed")
111
+ return out
112
+ out.steps = [f"run: {exe} plugin uninstall {PLUGIN}"]
113
+ if apply:
114
+ try:
115
+ run_native([exe, "plugin", "uninstall", PLUGIN])
116
+ except InstallError as exc:
117
+ out.status = "error"
118
+ out.notes.append(str(exc))
119
+ return out
120
+ out.status = "done"
121
+ return out
122
+
123
+
124
+ def doctor(c: Claude) -> dict:
125
+ exe = c.exe()
126
+ report = {"client": "claude", "scope": "user", "mode": "plugin", "present": bool(exe),
127
+ "version": c.version() if exe else None} # fmt: skip
128
+ if not exe:
129
+ return {**report, "connects": False, "error": "claude not on PATH"}
130
+ listing = _out([exe, "mcp", "list"], timeout=120)
131
+ line = next((x for x in listing.splitlines() if x.startswith(SERVER)), "")
132
+ duplicate = any(x.startswith("subtask:") for x in listing.splitlines())
133
+ return {**report, "configured": bool(line), "connects": "Connected" in line and "failed" not in line.lower(),
134
+ "line": line.strip()[:160], "duplicate_user_entry": duplicate} # fmt: skip
@@ -8,6 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from . import claude_plugin
11
12
  from .clients import CLIENTS, Client, InstallError
12
13
  from .doctor import doctor
13
14
  from .installer import install, uninstall
@@ -37,6 +38,9 @@ def add_parsers(sub: argparse._SubParsersAction) -> None:
37
38
  i.add_argument(
38
39
  "--force", action="store_true", help="replace an existing 'subtask' entry that is not ours"
39
40
  )
41
+ for p in (i, u, d):
42
+ p.add_argument("--no-plugin", action="store_true",
43
+ help="claude: register the server with `claude mcp add` instead of the plugin") # fmt: skip
40
44
  i.add_argument("--command", help="server command to register (default: subtask-mcp next to this python)")
41
45
 
42
46
 
@@ -63,8 +67,14 @@ def _selected(name: str) -> tuple[list[Client], list[str]]:
63
67
 
64
68
  def run(a: argparse.Namespace) -> int:
65
69
  clients, missing = _selected(a.client)
70
+ use_plugin = a.scope == "user" and not getattr(a, "no_plugin", False)
66
71
  if a.cmd == "doctor":
67
- reports = [doctor(c, a.scope) for c in clients]
72
+ reports = [
73
+ claude_plugin.doctor(c)
74
+ if use_plugin and c.name == "claude" and _plugin_mode(c)
75
+ else doctor(c, a.scope)
76
+ for c in clients
77
+ ]
68
78
  ok = all(r.get("connects") for r in reports) and bool(reports)
69
79
  _emit(a.json, {"action": "doctor", "clients": reports, "not_detected": missing}, _doctor_text)
70
80
  return 0 if ok else 1
@@ -80,9 +90,18 @@ def run(a: argparse.Namespace) -> int:
80
90
  results.append({"client": c.name, "scope": a.scope, "status": "skipped",
81
91
  "steps": [], "notes": [f"{c.binary} not found on PATH; install the app first"]}) # fmt: skip
82
92
  continue
83
- out = (
84
- install(c, a.scope, argv, apply, a.force) if a.cmd == "install" else uninstall(c, a.scope, apply)
85
- )
93
+ if c.name == "claude" and use_plugin:
94
+ out = (
95
+ claude_plugin.install(c, apply, a.force)
96
+ if a.cmd == "install"
97
+ else claude_plugin.uninstall(c, apply)
98
+ )
99
+ else:
100
+ out = (
101
+ install(c, a.scope, argv, apply, a.force)
102
+ if a.cmd == "install"
103
+ else uninstall(c, a.scope, apply)
104
+ )
86
105
  results.append(out.as_dict())
87
106
  data = {"action": a.cmd, "applied": apply, "command": argv, "clients": results, "not_detected": missing}
88
107
  _emit(a.json, data, _plan_text)
@@ -90,6 +109,13 @@ def run(a: argparse.Namespace) -> int:
90
109
  return 1 if bad else 0
91
110
 
92
111
 
112
+ def _plugin_mode(c) -> bool:
113
+ try:
114
+ return claude_plugin.state(c)["plugin"]
115
+ except InstallError:
116
+ return False
117
+
118
+
93
119
  def _emit(as_json: bool, data: dict[str, Any], render) -> None:
94
120
  print(json.dumps(data, indent=2) if as_json else render(data))
95
121
 
@@ -111,6 +137,17 @@ def _doctor_text(d: dict[str, Any]) -> str:
111
137
  for r in d["clients"]:
112
138
  hs = r.get("handshake") or {}
113
139
  lines.append(f"[{r['client']}] app: {'yes ' + str(r['version']) if r['present'] else 'not found'}")
140
+ if r.get("mode") == "plugin":
141
+ lines.append(f" plugin: {r.get('line') or 'not listed by claude mcp list'}")
142
+ lines.append(
143
+ f" connects: {'yes' if r.get('connects') else 'no'}"
144
+ + (
145
+ " WARNING: also a separate user-scope 'subtask' entry"
146
+ if r.get("duplicate_user_entry")
147
+ else ""
148
+ )
149
+ )
150
+ continue
114
151
  lines.append(f" config: {r.get('config')} configured: {'yes' if r.get('configured') else 'no'}")
115
152
  if r.get("command"):
116
153
  lines.append(f" command: {' '.join(r['command'])} exists: {r.get('command_exists')}")
@@ -147,6 +147,13 @@ def main() -> int:
147
147
  error=None,
148
148
  )
149
149
  emit("start", f"{spec['engine']} attempt {attempt}", state="running")
150
+ if adapter.capabilities.paid and not rid:
151
+ # Started by an older server that left the booking to the worker (v0 jobs):
152
+ # reserve here, so the spend still counts against the monthly cap.
153
+ rid = f"{job_id}#w{attempt}-{os.getpid()}"
154
+ if ledger.reserve(rid, job_id, float(spec["max_usd"])) is None:
155
+ rid = None
156
+ raise RuntimeError(f"monthly cap {config.MONTHLY_CAP_USD} $ reached")
150
157
  if not Path(spec["cwd"]).is_dir():
151
158
  raise RuntimeError(f"working folder is gone: {spec['cwd']} (cleaned up?)")
152
159
  level = (spec.get("policy") or {}).get("effective")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: subtask-mcp
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/tedin7/subtask
@@ -70,7 +70,7 @@ The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
70
70
  project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
71
71
 
72
72
  ```sh
73
- uv tool install "git+https://github.com/tedin7/subtask@v0.2.3" # or: uv tool install /path/to/checkout
73
+ uv tool install "git+https://github.com/tedin7/subtask@v0.2.6" # or: uv tool install /path/to/checkout
74
74
  subtask install --client all # prints the plan for every detected client
75
75
  subtask install --client all --yes # applies it: MCP server + skill
76
76
  subtask doctor --client all # checks app, config entry and a real handshake
@@ -37,6 +37,7 @@ subtask/engines/kimi.py
37
37
  subtask/engines/opencode.py
38
38
  subtask/engines/registry.py
39
39
  subtask/install/__init__.py
40
+ subtask/install/claude_plugin.py
40
41
  subtask/install/clients.py
41
42
  subtask/install/commands.py
42
43
  subtask/install/doctor.py
@@ -273,3 +273,27 @@ def test_missing_active_index_does_not_forget_running_jobs(home):
273
273
  assert sorted(admission.active()) == sorted([first, second])
274
274
  core.stop(first)
275
275
  core.stop(second)
276
+
277
+
278
+ def test_job_started_by_an_old_server_still_books_its_spend(home, monkeypatch):
279
+ """v0 spec/status (no attempts, no reservation): the worker reserves and settles itself."""
280
+ import signal
281
+
282
+ from subtask import jobs, ledger, worker
283
+
284
+ job = "st-v0v0v0v0v0"
285
+ folder = jobs.job_dir(job)
286
+ folder.mkdir(parents=True)
287
+ jobs.write_json(folder / "spec.json", {"task": "x", "engine": "fake", "cwd": str(home), "max_usd": 2.0,
288
+ "max_turns": 5, "effort": "low", "created": time.time()}) # fmt: skip
289
+ jobs.write_json(folder / "status.json", {"state": "queued"})
290
+ monkeypatch.setenv("SUBTASK_FAKE_SCRIPT", 'echo \'{"say":"ok"}\'')
291
+ monkeypatch.setattr(sys, "argv", ["worker", job])
292
+ old = signal.getsignal(signal.SIGTERM)
293
+ try:
294
+ worker.main()
295
+ finally:
296
+ signal.signal(signal.SIGTERM, old)
297
+ assert jobs.status(job)["state"] == "done"
298
+ assert ledger.committed() == pytest.approx(0.25)
299
+ assert ledger.open_for(job) == []
@@ -46,7 +46,8 @@ def env():
46
46
 
47
47
 
48
48
  def cli(e: dict, *args: str, cwd: Path | None = None) -> tuple[int, dict]:
49
- proc = subprocess.run([sys.executable, "-m", "subtask.cli", *args, "--json"], env=e,
49
+ # These tests cover the `claude mcp add` route; plugin mode has its own tests below.
50
+ proc = subprocess.run([sys.executable, "-m", "subtask.cli", *args, "--json", "--no-plugin"], env=e,
50
51
  capture_output=True, text=True, timeout=120, cwd=cwd or e["HOME"], check=False) # fmt: skip
51
52
  return proc.returncode, json.loads(proc.stdout)
52
53
 
@@ -364,3 +365,54 @@ def test_opencode_jsonc_is_read_and_never_rewritten_with_comments(tmp_path, monk
364
365
  assert (
365
366
  clients.strip_jsonc('{"a": "http://x//y", /* c */ "b": [1,],}') == '{"a": "http://x//y", "b": [1]}'
366
367
  )
368
+
369
+
370
+ def test_claude_plugin_mode_replaces_the_legacy_entry_and_skill(tmp_path, monkeypatch):
371
+ from subtask.install import claude_plugin, clients, ownership
372
+ from subtask.install.fileops import sha256
373
+
374
+ monkeypatch.setenv("HOME", str(tmp_path))
375
+ c = clients.CLIENTS["claude"]()
376
+ (tmp_path / ".claude.json").write_text(
377
+ json.dumps({"mcpServers": {"subtask": {"command": "/x/bin/subtask-mcp", "args": []}}})
378
+ )
379
+ skill = c.skill_path("user")
380
+ skill.parent.mkdir(parents=True)
381
+ skill.write_bytes(b"old skill")
382
+ ownership.put(ownership.key("claude", "user", "skill", skill), {"sha": sha256(b"old skill")})
383
+ ran: list[list[str]] = []
384
+ listings = {
385
+ "marketplace": "Configured marketplaces:\n\n ❯ claude-code-lsps\n",
386
+ "plugin": "Installed plugins:\n",
387
+ }
388
+ monkeypatch.setattr(
389
+ claude_plugin,
390
+ "_out",
391
+ lambda argv, timeout=60: listings["marketplace" if "marketplace" in argv else "plugin"],
392
+ )
393
+ monkeypatch.setattr(claude_plugin, "run_native", lambda argv: ran.append(argv))
394
+ monkeypatch.setattr(c, "exe", lambda: "claude")
395
+ plan = claude_plugin.install(c, apply=False, force=False)
396
+ assert ran == [] and skill.is_file() # plan only
397
+ out = claude_plugin.install(c, apply=True, force=False)
398
+ assert out.status == "done", out.notes
399
+ assert ran == [
400
+ ["claude", "plugin", "marketplace", "add", "tedin7/subtask"],
401
+ ["claude", "plugin", "install", "subtask@subtask"],
402
+ ["claude", "mcp", "remove", "-s", "user", "subtask"],
403
+ ]
404
+ assert not skill.exists() and plan.steps[-1].startswith("remove:")
405
+
406
+
407
+ def test_claude_plugin_mode_refuses_a_foreign_entry_without_force(tmp_path, monkeypatch):
408
+ from subtask.install import claude_plugin, clients
409
+
410
+ monkeypatch.setenv("HOME", str(tmp_path))
411
+ c = clients.CLIENTS["claude"]()
412
+ (tmp_path / ".claude.json").write_text(
413
+ json.dumps({"mcpServers": {"subtask": {"command": "/other/tool"}}})
414
+ )
415
+ monkeypatch.setattr(claude_plugin, "_out", lambda argv, timeout=60: "")
416
+ monkeypatch.setattr(claude_plugin, "run_native", lambda argv: (_ for _ in ()).throw(AssertionError(argv)))
417
+ monkeypatch.setattr(c, "exe", lambda: "claude")
418
+ assert claude_plugin.install(c, apply=True, force=False).status == "collision"
@@ -196,16 +196,23 @@ def test_doctor_reports_without_spending(home):
196
196
 
197
197
 
198
198
  def test_active_index_makes_counting_independent_of_history(home):
199
+ """Counting must not grow with finished jobs: compare with an empty history, not a fixed time."""
199
200
  from subtask import admission, config, jobs
200
201
 
202
+ def per_call() -> float:
203
+ t = time.perf_counter()
204
+ for _ in range(20):
205
+ admission.count()
206
+ return (time.perf_counter() - t) / 20
207
+
208
+ admission.count()
209
+ empty = per_call()
201
210
  for i in range(300):
202
211
  folder = config.JOBS / f"st-old{i:05d}"
203
212
  folder.mkdir(parents=True)
204
213
  jobs.write_json(folder / "status.json", {"state": "done"})
205
- t = time.perf_counter()
206
- for _ in range(20):
207
- admission.count()
208
- assert (time.perf_counter() - t) / 20 < 0.02 # does not scan 300 finished jobs
214
+ full = per_call()
215
+ assert full < empty * 3 + 0.005, (empty, full) # O(history) would be ~300x
209
216
 
210
217
 
211
218
  def test_narrator_context_includes_observed_activity_not_only_intentions(home):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes