subtask-mcp 0.2.7__tar.gz → 0.2.9__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.
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/PKG-INFO +2 -2
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/README.md +1 -1
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/__init__.py +1 -1
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/cli.py +7 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/core.py +9 -2
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/fastserver.py +3 -1
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/narrate.py +2 -2
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/server.py +4 -2
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/skill/SKILL.md +12 -0
- subtask_mcp-0.2.9/subtask/stephook.py +180 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/tools.json +1 -1
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/PKG-INFO +2 -2
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/SOURCES.txt +3 -0
- subtask_mcp-0.2.9/tests/test_live.py +76 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_protocol.py +16 -0
- subtask_mcp-0.2.9/tests/test_stephook.py +89 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/LICENSE +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/pyproject.toml +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/setup.cfg +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/admission.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/capacity.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/config.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/doctor.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/__init__.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/base.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/claude_api.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/codex.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/demo.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/dsh.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/kimi.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/opencode.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/engines/registry.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/events.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/evidence.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/gc.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/__init__.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/claude_plugin.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/clients.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/commands.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/doctor.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/fileops.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/installer.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/ownership.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/install/tomledit.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/jobs.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/ledger.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/policy.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/procid.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/progress.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/redact.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/roi.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/router.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/runners.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/schemacheck.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/telemetry.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask/worker.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/dependency_links.txt +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/entry_points.txt +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/requires.txt +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/subtask_mcp.egg-info/top_level.txt +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_concurrency.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_engines.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_falsify.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_independent_client.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_install.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_routing_results.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_schema.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/tests/test_security.py +0 -0
- {subtask_mcp-0.2.7 → subtask_mcp-0.2.9}/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
|
+
Version: 0.2.9
|
|
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.
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.9" # 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.
|
|
42
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.9" # 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
|
|
@@ -65,6 +65,9 @@ def main() -> int:
|
|
|
65
65
|
sp.add_argument("--how", required=True)
|
|
66
66
|
sp.add_argument("--decision", action="append", dest="decisions")
|
|
67
67
|
sub.add_parser("steps")
|
|
68
|
+
sub.add_parser(
|
|
69
|
+
"narrate-hook", help="Claude Code PreToolUse hook: automatic step announcement (reads JSON on stdin)"
|
|
70
|
+
)
|
|
68
71
|
sub.add_parser("spend")
|
|
69
72
|
sub.add_parser("engines")
|
|
70
73
|
install_commands.add_parsers(sub)
|
|
@@ -83,6 +86,10 @@ def main() -> int:
|
|
|
83
86
|
wa.add_argument("--interval", type=float, default=2.0)
|
|
84
87
|
a = p.parse_args()
|
|
85
88
|
|
|
89
|
+
if a.cmd == "narrate-hook":
|
|
90
|
+
from . import stephook
|
|
91
|
+
|
|
92
|
+
return stephook.main()
|
|
86
93
|
if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
|
|
87
94
|
return install_commands.run(a)
|
|
88
95
|
|
|
@@ -306,7 +306,12 @@ def _delta(job_ids: list[str], since: str) -> tuple[str, str]:
|
|
|
306
306
|
|
|
307
307
|
|
|
308
308
|
def wait(
|
|
309
|
-
job_ids: list[str],
|
|
309
|
+
job_ids: list[str],
|
|
310
|
+
timeout: int = 50,
|
|
311
|
+
any_: bool = False,
|
|
312
|
+
results: bool = True,
|
|
313
|
+
since: str | None = None,
|
|
314
|
+
count: bool = True,
|
|
310
315
|
) -> str:
|
|
311
316
|
"""Block until all jobs end (or the first one, with any_), or timeout.
|
|
312
317
|
|
|
@@ -320,7 +325,8 @@ def wait(
|
|
|
320
325
|
unknown = [j for j in job_ids if not known(j)]
|
|
321
326
|
if unknown:
|
|
322
327
|
return "unknown job: " + ", ".join(unknown)
|
|
323
|
-
|
|
328
|
+
if count: # servers poll wait() every second inside one tool call: count that call once
|
|
329
|
+
telemetry.count_call(job_ids, "wait")
|
|
324
330
|
deadline = time.time() + max(0, min(timeout, 600))
|
|
325
331
|
while True:
|
|
326
332
|
ended = [j for j in job_ids if jobs.status(j).get("state") in jobs.FINAL]
|
|
@@ -590,6 +596,7 @@ def step(
|
|
|
590
596
|
out = f"STEP: {entry['title']}\n{explained}"
|
|
591
597
|
entry["narrator"] = "model"
|
|
592
598
|
entry["announcement"] = out
|
|
599
|
+
entry["epoch"] = time.time()
|
|
593
600
|
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
594
601
|
with STEPS.open("a", encoding="utf-8") as fh:
|
|
595
602
|
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
@@ -89,8 +89,10 @@ def _wait(
|
|
|
89
89
|
if core.known(job_id):
|
|
90
90
|
call.reporter.add(job_id)
|
|
91
91
|
call.reporter.poll()
|
|
92
|
+
first = True
|
|
92
93
|
while True:
|
|
93
|
-
out = core.wait(ids, 0, any, since=since)
|
|
94
|
+
out = core.wait(ids, 0, any, since=since, count=first)
|
|
95
|
+
first = False
|
|
94
96
|
if not out.startswith("still running") or time.monotonic() >= deadline:
|
|
95
97
|
if call.reporter:
|
|
96
98
|
call.reporter.poll()
|
|
@@ -81,7 +81,7 @@ def observed_activity(repo: str, limit: int = 5) -> str:
|
|
|
81
81
|
return "\n".join(lines)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def _call(spec: tuple[str, float, float], system: str, user: str) -> str:
|
|
84
|
+
def _call(spec: tuple[str, float, float], system: str, user: str, timeout: float = 60) -> str:
|
|
85
85
|
"""One Messages API call, booked in the ledger. Raises on any failure."""
|
|
86
86
|
model, price_in, price_out = spec
|
|
87
87
|
secrets = config.load_secrets()
|
|
@@ -112,7 +112,7 @@ def _call(spec: tuple[str, float, float], system: str, user: str) -> str:
|
|
|
112
112
|
req = urllib.request.Request(
|
|
113
113
|
"https://api.anthropic.com/v1/messages", data=json.dumps(body).encode(), headers=headers
|
|
114
114
|
)
|
|
115
|
-
with urllib.request.urlopen(req, timeout=
|
|
115
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
116
116
|
data = json.load(resp)
|
|
117
117
|
usage = data.get("usage", {})
|
|
118
118
|
cost = (usage.get("input_tokens", 0) * price_in + usage.get("output_tokens", 0) * price_out) / 1e6
|
|
@@ -16,7 +16,7 @@ from . import core
|
|
|
16
16
|
mcp = FastMCP(
|
|
17
17
|
"subtask",
|
|
18
18
|
instructions=(
|
|
19
|
-
"
|
|
19
|
+
"Delegate long work to cheap engines; do small things yourself (measured: small tasks cost 2-3x). "
|
|
20
20
|
"Pattern: ONE start_many([...]) with every job, then ONE wait(ids): it blocks and returns "
|
|
21
21
|
"the summaries. Never poll status(); call wait() again only if it said 'still running'. "
|
|
22
22
|
"result(id, full/evidence) only when the summary is not enough. "
|
|
@@ -77,12 +77,14 @@ async def wait(
|
|
|
77
77
|
|
|
78
78
|
reporter = Reporter(send, [i for i in ids if core.known(i)])
|
|
79
79
|
deadline = loop.time() + max(1, min(timeout, 600))
|
|
80
|
+
first = True
|
|
80
81
|
while True:
|
|
81
82
|
if reporter:
|
|
82
83
|
reporter.poll()
|
|
83
84
|
while pending:
|
|
84
85
|
await pending.pop(0) # never after the result: progress stops at completion
|
|
85
|
-
out = core.wait(ids, 0, any, since=since)
|
|
86
|
+
out = core.wait(ids, 0, any, since=since, count=first)
|
|
87
|
+
first = False
|
|
86
88
|
if not out.startswith("still running") or loop.time() >= deadline:
|
|
87
89
|
return out
|
|
88
90
|
await asyncio.sleep(1)
|
|
@@ -17,6 +17,12 @@ Delegate when one of these is true:
|
|
|
17
17
|
- complex code needs a reviewer who did not write it (concurrency, saved data
|
|
18
18
|
or migrations, security, money, logic shared by many callers).
|
|
19
19
|
|
|
20
|
+
Measured (docs/benchmark.md): on small tasks delegating cost 2-3x the money
|
|
21
|
+
and time for the same result; on long, many-step pieces given to cheaper
|
|
22
|
+
engines (haiku-api, opencode-go, kimi) it cost 38-57 % less with about half
|
|
23
|
+
the parent context, but took 2-8x longer. So: delegate long work to cheap
|
|
24
|
+
engines; do small things yourself.
|
|
25
|
+
|
|
20
26
|
Do NOT delegate when:
|
|
21
27
|
- the change is small (a few lines, one file) or you already know the fix;
|
|
22
28
|
- it needs what is in your context right now (a decision you just made with
|
|
@@ -73,6 +79,12 @@ Do NOT delegate when:
|
|
|
73
79
|
|
|
74
80
|
## Announce every significant step
|
|
75
81
|
|
|
82
|
+
With the Claude Code plugin installed, a hook announces steps automatically
|
|
83
|
+
before file changes and commits (at most every 20 minutes per repository),
|
|
84
|
+
shown only to the user: you spend no turn on it. Call `step` yourself only for
|
|
85
|
+
a decision that is not a file change (a choice of approach, a release).
|
|
86
|
+
|
|
87
|
+
|
|
76
88
|
Before each significant step (new phase, choice of approach, a change with
|
|
77
89
|
risk, a merge or release) call `step(title, path, how, decisions)` and show
|
|
78
90
|
the returned text to the user verbatim. A separate cheap model reads your
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Automatic step announcements from a Claude Code PreToolUse hook, without the agent.
|
|
2
|
+
|
|
3
|
+
The agent does not have to call step(): before a file change or a commit, the hook
|
|
4
|
+
reads three facts and has the cheap narrator explain them to the user:
|
|
5
|
+
|
|
6
|
+
- the user's latest request (the "why"), from the session transcript;
|
|
7
|
+
- what the agent did so far (files read, commands run), from the transcript;
|
|
8
|
+
- the exact change about to happen (Edit/Write content, or the git command).
|
|
9
|
+
|
|
10
|
+
The explanation is returned as a hook `systemMessage`: shown to the user in the chat,
|
|
11
|
+
never sent to the agent, so the agent spends no turn and no token. At most once per
|
|
12
|
+
SUBTASK_STEP_EVERY_MIN minutes (default 20) per repository; a manual step() also counts.
|
|
13
|
+
Any failure lets the tool call through silently (logged in steps.jsonl).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
EVERY_S = float(os.environ.get("SUBTASK_STEP_EVERY_MIN", "20")) * 60
|
|
25
|
+
TIMEOUT_S = float(os.environ.get("SUBTASK_STEP_HOOK_TIMEOUT_S", "10"))
|
|
26
|
+
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
27
|
+
MAX_CHANGE = 2500
|
|
28
|
+
SYSTEM = (
|
|
29
|
+
"You are an independent observer of a coding agent. You did not plan its work. "
|
|
30
|
+
"From the facts below, explain to the operator, who is smart but not technical, what is about "
|
|
31
|
+
"to happen and why: what the user asked, what the agent has done so far, what this change "
|
|
32
|
+
"does. Short sentences, plain words, no em dashes, at most 5 bullets. Base every statement on "
|
|
33
|
+
"the facts given; if the reason for the change is not clear from them, say so. If the change "
|
|
34
|
+
"does not fit the user's request, say it first. Write in this language: {lang}."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _text(content: object) -> str:
|
|
39
|
+
if isinstance(content, str):
|
|
40
|
+
return content
|
|
41
|
+
if isinstance(content, list):
|
|
42
|
+
return " ".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_transcript(path: str | None, max_bytes: int = 400_000) -> tuple[str, list[str]]:
|
|
47
|
+
"""(last user request, recent agent actions) from a Claude Code session transcript."""
|
|
48
|
+
if not path or not Path(path).is_file():
|
|
49
|
+
return "", []
|
|
50
|
+
with open(path, "rb") as fh:
|
|
51
|
+
fh.seek(0, 2)
|
|
52
|
+
fh.seek(max(0, fh.tell() - max_bytes))
|
|
53
|
+
lines = fh.read().decode("utf-8", errors="replace").splitlines()
|
|
54
|
+
request, actions = "", []
|
|
55
|
+
for line in lines:
|
|
56
|
+
try:
|
|
57
|
+
row = json.loads(line)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
continue
|
|
60
|
+
msg = row.get("message") if isinstance(row, dict) else None
|
|
61
|
+
if not isinstance(msg, dict):
|
|
62
|
+
continue
|
|
63
|
+
content = msg.get("content")
|
|
64
|
+
if row.get("type") == "user":
|
|
65
|
+
is_tool_result = isinstance(content, list) and any(
|
|
66
|
+
isinstance(b, dict) and b.get("type") == "tool_result" for b in content
|
|
67
|
+
)
|
|
68
|
+
text = _text(content).strip()
|
|
69
|
+
if text and not is_tool_result and not text.startswith("<"):
|
|
70
|
+
request, actions = text, [] # a new request starts a new chain of actions
|
|
71
|
+
elif row.get("type") == "assistant" and isinstance(content, list):
|
|
72
|
+
for b in content:
|
|
73
|
+
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
74
|
+
actions.append(_action(b.get("name", ""), b.get("input") or {}))
|
|
75
|
+
return request[-2000:], [a for a in actions if a][-12:]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _action(name: str, args: dict) -> str:
|
|
79
|
+
target = args.get("file_path") or args.get("path") or args.get("pattern") or args.get("command") or ""
|
|
80
|
+
return f"{name} {str(target)[:120]}".strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def describe_change(tool: str, args: dict) -> str | None:
|
|
84
|
+
if tool in EDIT_TOOLS:
|
|
85
|
+
path = args.get("file_path") or args.get("notebook_path") or "?"
|
|
86
|
+
if tool == "Write":
|
|
87
|
+
body = f"new content:\n{str(args.get('content', ''))[:MAX_CHANGE]}"
|
|
88
|
+
elif tool == "MultiEdit":
|
|
89
|
+
parts = [
|
|
90
|
+
f"- {e.get('old_string', '')[:300]}\n+ {e.get('new_string', '')[:300]}"
|
|
91
|
+
for e in args.get("edits", [])
|
|
92
|
+
]
|
|
93
|
+
body = "\n".join(parts)[:MAX_CHANGE]
|
|
94
|
+
else:
|
|
95
|
+
body = f"- {str(args.get('old_string', ''))[: MAX_CHANGE // 2]}\n+ {str(args.get('new_string', ''))[: MAX_CHANGE // 2]}"
|
|
96
|
+
return f"{tool} {path}\n{body}"
|
|
97
|
+
if tool == "Bash":
|
|
98
|
+
cmd = str(args.get("command", ""))
|
|
99
|
+
if "git commit" in cmd or "git merge" in cmd or "git push" in cmd:
|
|
100
|
+
return f"Bash: {cmd[:MAX_CHANGE]}"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _recent(repo: str) -> bool:
|
|
105
|
+
from .core import STEPS
|
|
106
|
+
|
|
107
|
+
if not STEPS.is_file():
|
|
108
|
+
return False
|
|
109
|
+
with STEPS.open("rb") as fh:
|
|
110
|
+
fh.seek(0, 2)
|
|
111
|
+
fh.seek(max(0, fh.tell() - 200_000))
|
|
112
|
+
lines = fh.read().decode("utf-8", errors="replace").splitlines()
|
|
113
|
+
now = time.time()
|
|
114
|
+
for line in reversed(lines):
|
|
115
|
+
try:
|
|
116
|
+
row = json.loads(line)
|
|
117
|
+
except json.JSONDecodeError:
|
|
118
|
+
continue
|
|
119
|
+
if row.get("repo") != repo:
|
|
120
|
+
continue
|
|
121
|
+
ts = row.get("epoch") or time.mktime(time.strptime(row["ts"], "%Y-%m-%d %H:%M:%S"))
|
|
122
|
+
return now - float(ts) < EVERY_S
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _log(entry: dict) -> None:
|
|
127
|
+
from . import config
|
|
128
|
+
from .core import STEPS
|
|
129
|
+
|
|
130
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
with STEPS.open("a", encoding="utf-8") as fh:
|
|
132
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def handle(event: dict) -> dict | None:
|
|
136
|
+
"""The hook's JSON answer, or None to stay silent."""
|
|
137
|
+
from . import narrate
|
|
138
|
+
from .core import _repo_of
|
|
139
|
+
from .redact import redact
|
|
140
|
+
|
|
141
|
+
change = describe_change(str(event.get("tool_name", "")), event.get("tool_input") or {})
|
|
142
|
+
if not change:
|
|
143
|
+
return None
|
|
144
|
+
args = event.get("tool_input") or {}
|
|
145
|
+
path = args.get("file_path") or args.get("notebook_path") or event.get("cwd") or os.getcwd()
|
|
146
|
+
repo = _repo_of(str(path)) or _repo_of(str(event.get("cwd") or "")) or str(event.get("cwd") or "")
|
|
147
|
+
if not repo or _recent(repo):
|
|
148
|
+
return None
|
|
149
|
+
request, actions = read_transcript(event.get("transcript_path"))
|
|
150
|
+
facts = (
|
|
151
|
+
f"User's latest request:\n{request or '(not found)'}\n\n"
|
|
152
|
+
f"Agent's recent actions:\n" + ("\n".join(actions) or "(none recorded)") + "\n\n"
|
|
153
|
+
f"Change about to happen:\n{change}\n\nRepository state:\n{narrate.repo_context(repo)}"
|
|
154
|
+
)
|
|
155
|
+
lang = os.environ.get("SUBTASK_STEP_LANG", "Italian")
|
|
156
|
+
entry = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "epoch": time.time(), "repo": repo, "source": "hook",
|
|
157
|
+
"title": change.splitlines()[0][:100], "path": str(path), "how": "automatic (PreToolUse hook)"} # fmt: skip
|
|
158
|
+
t0 = time.monotonic()
|
|
159
|
+
try:
|
|
160
|
+
text = narrate._call(narrate.EXPLAINER, SYSTEM.format(lang=lang), redact(facts), timeout=TIMEOUT_S)
|
|
161
|
+
except Exception as exc: # noqa: BLE001 - the work must never be blocked by the narrator
|
|
162
|
+
_log({**entry, "narrator": "failed", "error": f"{type(exc).__name__}: {exc}"[:200]})
|
|
163
|
+
return None
|
|
164
|
+
entry.update(narrator="model", narrator_ms=round((time.monotonic() - t0) * 1000), announcement=text)
|
|
165
|
+
_log(entry)
|
|
166
|
+
return {"systemMessage": f"STEP · {Path(repo).name}\n{text}", "suppressOutput": True}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main() -> int:
|
|
170
|
+
try:
|
|
171
|
+
event = json.load(sys.stdin)
|
|
172
|
+
except (json.JSONDecodeError, ValueError):
|
|
173
|
+
return 0
|
|
174
|
+
try:
|
|
175
|
+
answer = handle(event) if isinstance(event, dict) else None
|
|
176
|
+
except Exception: # noqa: BLE001 - a hook must exit 0 so the tool call proceeds
|
|
177
|
+
answer = None
|
|
178
|
+
if answer:
|
|
179
|
+
print(json.dumps(answer, ensure_ascii=False))
|
|
180
|
+
return 0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"instructions": "
|
|
2
|
+
"instructions": "Delegate long work to cheap engines; do small things yourself (measured: small tasks cost 2-3x). Pattern: ONE start_many([...]) with every job, then ONE wait(ids): it blocks and returns the summaries. Never poll status(); call wait() again only if it said 'still running'. result(id, full/evidence) only when the summary is not enough. Call step() before each significant step.",
|
|
3
3
|
"tools": [
|
|
4
4
|
{
|
|
5
5
|
"name": "start",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: subtask-mcp
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.9
|
|
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.
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.9" # 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
|
|
@@ -24,6 +24,7 @@ subtask/router.py
|
|
|
24
24
|
subtask/runners.py
|
|
25
25
|
subtask/schemacheck.py
|
|
26
26
|
subtask/server.py
|
|
27
|
+
subtask/stephook.py
|
|
27
28
|
subtask/telemetry.py
|
|
28
29
|
subtask/tools.json
|
|
29
30
|
subtask/worker.py
|
|
@@ -57,8 +58,10 @@ tests/test_engines.py
|
|
|
57
58
|
tests/test_falsify.py
|
|
58
59
|
tests/test_independent_client.py
|
|
59
60
|
tests/test_install.py
|
|
61
|
+
tests/test_live.py
|
|
60
62
|
tests/test_protocol.py
|
|
61
63
|
tests/test_routing_results.py
|
|
62
64
|
tests/test_schema.py
|
|
63
65
|
tests/test_security.py
|
|
66
|
+
tests/test_stephook.py
|
|
64
67
|
tests/test_subtask.py
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Live adapter checks: one tiny real job per engine, through subtask itself.
|
|
2
|
+
|
|
3
|
+
Marked `live`: skipped by default (pytest.ini addopts "-m 'not live'"). Run with
|
|
4
|
+
`pytest -m live` when spending is authorized. Each job is capped at 0.20 USD
|
|
5
|
+
(paid engines) and asks for one shell command and a one-word answer, so the
|
|
6
|
+
contract with the real CLI/SDK is exercised: argv accepted, events parsed,
|
|
7
|
+
session id found, result written, cost booked.
|
|
8
|
+
Engines that are not installed or not logged in are skipped, never faked.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
pytestmark = pytest.mark.live
|
|
24
|
+
|
|
25
|
+
TASK = "Run the shell command `echo subtask-live-ok` once, then answer with exactly that output."
|
|
26
|
+
ENGINES = ["haiku-api", "opencode-go", "kimi", "codex"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cli(home: Path, *args: str, timeout: float = 300) -> str:
|
|
30
|
+
env = {**os.environ, "SUBTASK_HOME": str(home), "SUBTASK_MAX_RUNNING": "4"}
|
|
31
|
+
done = subprocess.run([sys.executable, "-m", "subtask.cli", *args], env=env, capture_output=True,
|
|
32
|
+
text=True, timeout=timeout, check=False) # fmt: skip
|
|
33
|
+
return done.stdout + done.stderr
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _available(engine: str) -> str | None:
|
|
37
|
+
from subtask import config
|
|
38
|
+
from subtask.engines import registry
|
|
39
|
+
|
|
40
|
+
adapter = registry.get(engine)
|
|
41
|
+
if adapter is None or not adapter.available():
|
|
42
|
+
return f"{engine} not installed"
|
|
43
|
+
if (
|
|
44
|
+
adapter.capabilities.paid
|
|
45
|
+
and engine.endswith("-api")
|
|
46
|
+
and not config.load_secrets().get("ANTHROPIC_API_KEY")
|
|
47
|
+
):
|
|
48
|
+
return "no ANTHROPIC_API_KEY in the subtask env file"
|
|
49
|
+
cap = adapter.capacity()
|
|
50
|
+
if cap.value is not None and cap.value < 10:
|
|
51
|
+
return f"{engine} has no quota left ({cap.note})"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("engine", ENGINES)
|
|
56
|
+
def test_engine_runs_a_tiny_real_job(engine, tmp_path):
|
|
57
|
+
reason = _available(engine)
|
|
58
|
+
if reason:
|
|
59
|
+
pytest.skip(reason)
|
|
60
|
+
if shutil.which("git") is None:
|
|
61
|
+
pytest.skip("git missing")
|
|
62
|
+
work = tmp_path / "work"
|
|
63
|
+
work.mkdir()
|
|
64
|
+
out = _cli(tmp_path / "home", "start", TASK, "--engine", engine, "--cwd", str(work), "--max-usd", "0.2",
|
|
65
|
+
"--max-turns", "6", "--effort", "low") # fmt: skip
|
|
66
|
+
assert " started " in out, out
|
|
67
|
+
job = out.split()[0]
|
|
68
|
+
t0 = time.time()
|
|
69
|
+
final = _cli(tmp_path / "home", "wait", job, "--timeout", "280")
|
|
70
|
+
assert " done " in final, final
|
|
71
|
+
env = json.loads(_cli(tmp_path / "home", "result", job, "--evidence"))
|
|
72
|
+
assert "subtask-live-ok" in env["summary"]["text"], env["summary"]
|
|
73
|
+
assert env["commands"]["seen"] >= 1, "no shell command observed: event format changed?"
|
|
74
|
+
if env["cost"]["reserved"] is not None:
|
|
75
|
+
assert 0 <= (env["cost"]["charged_total"] or 0) <= 0.2
|
|
76
|
+
assert time.time() - t0 < 300
|
|
@@ -262,3 +262,19 @@ def test_reporter_follows_the_latest_change_and_admits_missing_detail(home):
|
|
|
262
262
|
assert rep._what(b) == progress.NO_DETAIL # kimi plain output: no structured activity
|
|
263
263
|
core.stop(a)
|
|
264
264
|
core.stop(b)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_a_long_wait_counts_as_one_round_trip(home):
|
|
268
|
+
"""The server polls core.wait every second; that is not a parent round trip."""
|
|
269
|
+
from subtask import telemetry
|
|
270
|
+
|
|
271
|
+
fake_cli(home, "kimi", "sleep 4; echo done")
|
|
272
|
+
c = Client(_env())
|
|
273
|
+
c.send({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
|
274
|
+
"params": {"name": "start", "arguments": {"task": "x", "engine": "kimi", "cwd": str(home)}}}) # fmt: skip
|
|
275
|
+
job = c.until(1)[0]["result"]["content"][0]["text"].split()[0]
|
|
276
|
+
c.send({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
277
|
+
"params": {"name": "wait", "arguments": {"ids": [job], "timeout": 30}}}) # fmt: skip
|
|
278
|
+
c.until(2, timeout=40)
|
|
279
|
+
c.close()
|
|
280
|
+
assert [x["tool"] for x in telemetry.calls(job)].count("wait") == 1
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Automatic step announcements from the PreToolUse hook: facts in, user-only message out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _transcript(tmp_path):
|
|
11
|
+
rows = [
|
|
12
|
+
{"type": "user", "message": {"role": "user", "content": "rendi più veloce il conteggio dei job"}},
|
|
13
|
+
{"type": "assistant", "message": {"content": [
|
|
14
|
+
{"type": "tool_use", "name": "Read", "input": {"file_path": "/r/subtask/admission.py"}},
|
|
15
|
+
{"type": "tool_use", "name": "Bash", "input": {"command": "pytest -q tests/test_x.py"}}]}},
|
|
16
|
+
{"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "content": "ok"}]}},
|
|
17
|
+
] # fmt: skip
|
|
18
|
+
path = tmp_path / "t.jsonl"
|
|
19
|
+
path.write_text("\n".join(json.dumps(r) for r in rows))
|
|
20
|
+
return path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _repo(tmp_path):
|
|
24
|
+
repo = tmp_path / "repo"
|
|
25
|
+
repo.mkdir()
|
|
26
|
+
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
|
27
|
+
return repo
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_transcript_gives_request_and_actions_not_tool_results(tmp_path):
|
|
31
|
+
from subtask import stephook
|
|
32
|
+
|
|
33
|
+
request, actions = stephook.read_transcript(str(_transcript(tmp_path)))
|
|
34
|
+
assert request == "rendi più veloce il conteggio dei job"
|
|
35
|
+
assert actions == ["Read /r/subtask/admission.py", "Bash pytest -q tests/test_x.py"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_only_changes_and_commits_are_announced():
|
|
39
|
+
from subtask import stephook
|
|
40
|
+
|
|
41
|
+
assert stephook.describe_change("Read", {"file_path": "x"}) is None
|
|
42
|
+
assert stephook.describe_change("Bash", {"command": "ls"}) is None
|
|
43
|
+
assert "git commit" in stephook.describe_change("Bash", {"command": "git commit -m x"})
|
|
44
|
+
edit = stephook.describe_change("Edit", {"file_path": "/a.py", "old_string": "a", "new_string": "b"})
|
|
45
|
+
assert edit.startswith("Edit /a.py") and "- a" in edit and "+ b" in edit
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_hook_shows_the_explanation_to_the_user_once_per_window(home, tmp_path, monkeypatch):
|
|
49
|
+
from subtask import narrate, stephook
|
|
50
|
+
|
|
51
|
+
seen = {}
|
|
52
|
+
|
|
53
|
+
def fake_call(spec, system, user, timeout=60):
|
|
54
|
+
seen["user"] = user
|
|
55
|
+
return "- Hai chiesto un conteggio più veloce.\n- L'agente cambia admission.py."
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(narrate, "_call", fake_call)
|
|
58
|
+
repo = _repo(tmp_path)
|
|
59
|
+
event = {"tool_name": "Edit", "tool_input": {"file_path": str(repo / "a.py"), "old_string": "x", "new_string": "y"},
|
|
60
|
+
"transcript_path": str(_transcript(tmp_path)), "cwd": str(repo)} # fmt: skip
|
|
61
|
+
out = stephook.handle(event)
|
|
62
|
+
assert out["systemMessage"].startswith("STEP · repo") and "conteggio" in out["systemMessage"]
|
|
63
|
+
assert "rendi più veloce" in seen["user"] and "+ y" in seen["user"]
|
|
64
|
+
assert stephook.handle(event) is None # inside the 20-minute window: silent, no second call
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_narrator_failure_never_blocks_and_is_logged(home, tmp_path, monkeypatch):
|
|
68
|
+
from subtask import core, narrate, stephook
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(narrate, "_call", lambda *a, **k: (_ for _ in ()).throw(TimeoutError("slow")))
|
|
71
|
+
repo = _repo(tmp_path)
|
|
72
|
+
event = {
|
|
73
|
+
"tool_name": "Write",
|
|
74
|
+
"tool_input": {"file_path": str(repo / "b.py"), "content": "z"},
|
|
75
|
+
"cwd": str(repo),
|
|
76
|
+
}
|
|
77
|
+
assert stephook.handle(event) is None
|
|
78
|
+
assert '"narrator": "failed"' in core.STEPS.read_text()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_cli_hook_exits_zero_and_prints_json_only(home, tmp_path):
|
|
82
|
+
repo = _repo(tmp_path)
|
|
83
|
+
event = {"tool_name": "Read", "tool_input": {"file_path": str(repo / "a.py")}, "cwd": str(repo)}
|
|
84
|
+
done = subprocess.run([sys.executable, "-m", "subtask.cli", "narrate-hook"], input=json.dumps(event),
|
|
85
|
+
capture_output=True, text=True, timeout=30, check=False) # fmt: skip
|
|
86
|
+
assert done.returncode == 0 and done.stdout == ""
|
|
87
|
+
bad = subprocess.run([sys.executable, "-m", "subtask.cli", "narrate-hook"], input="not json",
|
|
88
|
+
capture_output=True, text=True, timeout=30, check=False) # fmt: skip
|
|
89
|
+
assert bad.returncode == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|