subtask-mcp 0.2.3__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.3/LICENSE +21 -0
- subtask_mcp-0.2.3/PKG-INFO +164 -0
- subtask_mcp-0.2.3/README.md +133 -0
- subtask_mcp-0.2.3/pyproject.toml +60 -0
- subtask_mcp-0.2.3/setup.cfg +4 -0
- subtask_mcp-0.2.3/subtask/__init__.py +3 -0
- subtask_mcp-0.2.3/subtask/admission.py +254 -0
- subtask_mcp-0.2.3/subtask/capacity.py +90 -0
- subtask_mcp-0.2.3/subtask/cli.py +183 -0
- subtask_mcp-0.2.3/subtask/config.py +59 -0
- subtask_mcp-0.2.3/subtask/core.py +603 -0
- subtask_mcp-0.2.3/subtask/doctor.py +120 -0
- subtask_mcp-0.2.3/subtask/engines/__init__.py +1 -0
- subtask_mcp-0.2.3/subtask/engines/base.py +297 -0
- subtask_mcp-0.2.3/subtask/engines/claude_api.py +204 -0
- subtask_mcp-0.2.3/subtask/engines/codex.py +182 -0
- subtask_mcp-0.2.3/subtask/engines/demo.py +50 -0
- subtask_mcp-0.2.3/subtask/engines/dsh.py +96 -0
- subtask_mcp-0.2.3/subtask/engines/kimi.py +75 -0
- subtask_mcp-0.2.3/subtask/engines/opencode.py +87 -0
- subtask_mcp-0.2.3/subtask/engines/registry.py +124 -0
- subtask_mcp-0.2.3/subtask/events.py +105 -0
- subtask_mcp-0.2.3/subtask/evidence.py +103 -0
- subtask_mcp-0.2.3/subtask/fastserver.py +272 -0
- subtask_mcp-0.2.3/subtask/gc.py +99 -0
- subtask_mcp-0.2.3/subtask/install/__init__.py +1 -0
- subtask_mcp-0.2.3/subtask/install/clients.py +355 -0
- subtask_mcp-0.2.3/subtask/install/commands.py +122 -0
- subtask_mcp-0.2.3/subtask/install/doctor.py +112 -0
- subtask_mcp-0.2.3/subtask/install/fileops.py +69 -0
- subtask_mcp-0.2.3/subtask/install/installer.py +204 -0
- subtask_mcp-0.2.3/subtask/install/ownership.py +44 -0
- subtask_mcp-0.2.3/subtask/install/tomledit.py +44 -0
- subtask_mcp-0.2.3/subtask/jobs.py +256 -0
- subtask_mcp-0.2.3/subtask/ledger.py +268 -0
- subtask_mcp-0.2.3/subtask/narrate.py +134 -0
- subtask_mcp-0.2.3/subtask/policy.py +58 -0
- subtask_mcp-0.2.3/subtask/procid.py +97 -0
- subtask_mcp-0.2.3/subtask/progress.py +152 -0
- subtask_mcp-0.2.3/subtask/redact.py +61 -0
- subtask_mcp-0.2.3/subtask/roi.py +84 -0
- subtask_mcp-0.2.3/subtask/router.py +202 -0
- subtask_mcp-0.2.3/subtask/runners.py +99 -0
- subtask_mcp-0.2.3/subtask/schemacheck.py +131 -0
- subtask_mcp-0.2.3/subtask/server.py +192 -0
- subtask_mcp-0.2.3/subtask/skill/SKILL.md +96 -0
- subtask_mcp-0.2.3/subtask/telemetry.py +76 -0
- subtask_mcp-0.2.3/subtask/tools.json +237 -0
- subtask_mcp-0.2.3/subtask/worker.py +278 -0
- subtask_mcp-0.2.3/subtask_mcp.egg-info/PKG-INFO +164 -0
- subtask_mcp-0.2.3/subtask_mcp.egg-info/SOURCES.txt +63 -0
- subtask_mcp-0.2.3/subtask_mcp.egg-info/dependency_links.txt +1 -0
- subtask_mcp-0.2.3/subtask_mcp.egg-info/entry_points.txt +4 -0
- subtask_mcp-0.2.3/subtask_mcp.egg-info/requires.txt +11 -0
- subtask_mcp-0.2.3/subtask_mcp.egg-info/top_level.txt +1 -0
- subtask_mcp-0.2.3/tests/test_concurrency.py +275 -0
- subtask_mcp-0.2.3/tests/test_engines.py +154 -0
- subtask_mcp-0.2.3/tests/test_falsify.py +340 -0
- subtask_mcp-0.2.3/tests/test_independent_client.py +53 -0
- subtask_mcp-0.2.3/tests/test_install.py +366 -0
- subtask_mcp-0.2.3/tests/test_protocol.py +264 -0
- subtask_mcp-0.2.3/tests/test_routing_results.py +218 -0
- subtask_mcp-0.2.3/tests/test_schema.py +100 -0
- subtask_mcp-0.2.3/tests/test_security.py +182 -0
- subtask_mcp-0.2.3/tests/test_subtask.py +364 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 subtask contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: subtask-mcp
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/tedin7/subtask
|
|
7
|
+
Project-URL: Repository, https://github.com/tedin7/subtask
|
|
8
|
+
Project-URL: Issues, https://github.com/tedin7/subtask/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: POSIX
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: claude-agent-sdk>=0.2
|
|
22
|
+
Requires-Dist: mcp<2,>=1.28
|
|
23
|
+
Provides-Extra: dsh
|
|
24
|
+
Requires-Dist: deepseek-harness-sdk==0.1.5rc1; extra == "dsh"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
28
|
+
Requires-Dist: jsonschema>=4; extra == "dev"
|
|
29
|
+
Requires-Dist: tqdm>=4; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# subtask
|
|
33
|
+
|
|
34
|
+
<!-- mcp-name: io.github.tedin7/subtask -->
|
|
35
|
+
|
|
36
|
+
A local delegation scheduler for coding agents. One MCP server (and a twin
|
|
37
|
+
CLI) lets Claude Code, Codex, Kimi Code or OpenCode start work in parallel,
|
|
38
|
+
wait for it with a single call, and read back a short summary with evidence.
|
|
39
|
+
It picks an engine that can take the job, books money and slots safely across
|
|
40
|
+
processes, and says honestly how isolated each run is.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
start_many([{task: "Fix the flaky test in tests/test_sync.py", repo: "/src/app", verify: "pytest -q tests/test_sync.py"},
|
|
44
|
+
{task: "List every caller of parse_date()", kind: "read", readonly: true, cwd: "/src/app"}])
|
|
45
|
+
-> st-4f2a91 started · sonnet-api · branch subtask/st-4f2a91 · sandbox
|
|
46
|
+
st-77c0de started · haiku-api · /src/app · readonly
|
|
47
|
+
wait(["st-4f2a91", "st-77c0de"]) # Claude Code shows: Subtask · 01:16 · 1/2 ended — st-4f2a91 running tests: pytest -q
|
|
48
|
+
-> st-4f2a91 done · sonnet-api · 6m · 0.412$ · verified · ...
|
|
49
|
+
## Summary (≤10 lines: the answer, files, commits, tests, what is left)
|
|
50
|
+
result("st-4f2a91", evidence=True) # JSON: base/head, changed files, observed commands, verify exit code, cost
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Why
|
|
54
|
+
|
|
55
|
+
- **Save the caller's context.** The worker's search, test runs and dead ends
|
|
56
|
+
stay in its own session; the caller reads about ten lines.
|
|
57
|
+
- **Few round trips.** `start_many` + one `wait` replace a polling loop.
|
|
58
|
+
- **Use whatever has room.** `engine="auto"` filters engines that can enforce
|
|
59
|
+
the requested isolation, are installed, fit the budget and have quota, then
|
|
60
|
+
ranks them with an explicit utility (`subtask routing explain`).
|
|
61
|
+
- **Money is a primitive.** Paid engines reserve their budget before any work,
|
|
62
|
+
under one lock with the slot; every reservation is closed exactly once.
|
|
63
|
+
- **Evidence, not claims.** `verify="..."` runs your check on the commit the
|
|
64
|
+
worker left; `result(evidence=True)` separates what subtask observed from
|
|
65
|
+
what the worker reported.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
70
|
+
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.3" # or: uv tool install /path/to/checkout
|
|
74
|
+
subtask install --client all # prints the plan for every detected client
|
|
75
|
+
subtask install --client all --yes # applies it: MCP server + skill
|
|
76
|
+
subtask doctor --client all # checks app, config entry and a real handshake
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Claude Code plugin: `claude plugin marketplace add tedin7/subtask` then
|
|
80
|
+
`claude plugin install subtask@subtask`. Codex: `codex plugin marketplace add tedin7/subtask`.
|
|
81
|
+
API engines read `ANTHROPIC_API_KEY` from `~/.config/subtask/env` (chmod 600).
|
|
82
|
+
|
|
83
|
+
## Engines
|
|
84
|
+
|
|
85
|
+
| engine | pays with | read-only | isolation it can enforce |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `haiku-api`, `sonnet-api`, `opus-api` | API key, monthly cap | yes | sandbox (Claude Code OS sandbox) |
|
|
88
|
+
| `codex` | Codex login (quota from local session files) | yes | sandbox (`workspace-write`), isolated |
|
|
89
|
+
| `opencode-go` | opencode-go subscription | no | worktree, isolated |
|
|
90
|
+
| `kimi` | Kimi login | no | worktree, isolated |
|
|
91
|
+
| `dsh` | DeepSeek key, same cap (`subtask-mcp[dsh]`) | no | worktree |
|
|
92
|
+
| `opencode` | OpenCode default provider (manual only) | no | worktree, isolated |
|
|
93
|
+
|
|
94
|
+
Add an engine without touching the core: an `EngineAdapter` registered under the
|
|
95
|
+
`subtask.engines` entry point (see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
|
96
|
+
|
|
97
|
+
## Isolation, honestly
|
|
98
|
+
|
|
99
|
+
- `worktree` (default): a git worktree and branch per writing job. It prevents
|
|
100
|
+
collisions; it is **not** a host sandbox.
|
|
101
|
+
- `sandbox`: the engine's own sandbox. Same user: a guard rail.
|
|
102
|
+
- `isolated`: bubblewrap. Read-only system, writable worktree and git dir only,
|
|
103
|
+
a private HOME with only the engine's own state. Needs `bwrap`.
|
|
104
|
+
- `untrusted`: sandbox or stronger, and no network for the worker's commands.
|
|
105
|
+
|
|
106
|
+
A job that cannot get the policy it asked for is refused, never downgraded.
|
|
107
|
+
Output is redacted for common secret formats before it is saved or shown;
|
|
108
|
+
redaction is a second layer, not a guarantee. See [SECURITY.md](SECURITY.md).
|
|
109
|
+
|
|
110
|
+
## Tools (MCP) and CLI
|
|
111
|
+
|
|
112
|
+
| tool | what it does |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify)` | start one job |
|
|
115
|
+
| `start_many(tasks)` | start several; partial success is listed per item |
|
|
116
|
+
| `wait(ids, timeout=300, any=False, since=None)` | block until they end; progress notifications while waiting; `since` cursor for clients without progress |
|
|
117
|
+
| `status(id)` / `result(id, full, evidence)` | one line / summary or full text or JSON envelope |
|
|
118
|
+
| `reply(id, message)` / `stop(id)` / `cleanup(id)` | continue / stop the work / remove a clean worktree (branch kept) |
|
|
119
|
+
| `engines()` | capacity per engine with source, freshness and confidence, plus spend |
|
|
120
|
+
| `step(title, path, how, decisions)` | an independent cheap model narrates the step from the repo's real state |
|
|
121
|
+
|
|
122
|
+
CLI only: `subtask doctor`, `gc [--apply]`, `du`, `roi`, `routing explain|calibrate`,
|
|
123
|
+
`watch ID...`, `install`, `uninstall`, and `--model`, `--no-sandbox`, `--untrusted` on start.
|
|
124
|
+
|
|
125
|
+
## Progress in the chat
|
|
126
|
+
|
|
127
|
+
| client | during a `wait` |
|
|
128
|
+
|---|---|
|
|
129
|
+
| Claude Code 2.1 (TUI) | live line under the tool call, verified |
|
|
130
|
+
| OpenCode 2.0 | notifications received, not shown: use `wait(since=...)` |
|
|
131
|
+
| Kimi Code 2.1 | client does not request progress: `wait(since=...)` |
|
|
132
|
+
| Codex 0.162 | not yet verified (account at its usage limit) |
|
|
133
|
+
|
|
134
|
+
Details and evidence: [docs/progress-matrix.md](docs/progress-matrix.md).
|
|
135
|
+
|
|
136
|
+
## Settings
|
|
137
|
+
|
|
138
|
+
| variable | default |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `SUBTASK_HOME` | `~/.local/share/subtask` (jobs, worktrees, ledger, telemetry) |
|
|
141
|
+
| `SUBTASK_ENV_FILE` | `~/.config/subtask/env` |
|
|
142
|
+
| `SUBTASK_MONTHLY_CAP_USD` / `SUBTASK_DEFAULT_MAX_USD` / `SUBTASK_MAX_JOB_USD` | `180` / `2` / `20` |
|
|
143
|
+
| `SUBTASK_MAX_RUNNING` | `4` (global across all processes) |
|
|
144
|
+
| `SUBTASK_ROUTING` | `~/.config/subtask/routing.json` (weights, `"mode": "legacy"`) |
|
|
145
|
+
| `SUBTASK_JOB_RETENTION_DAYS` / `SUBTASK_TELEMETRY_DAYS` | `30` / `90` |
|
|
146
|
+
| `SUBTASK_PROGRESS_MIN_S` / `SUBTASK_PROGRESS_HEARTBEAT_S` | `2` / `10` |
|
|
147
|
+
|
|
148
|
+
Costs of API engines come from the SDK's estimate; a run whose real cost is
|
|
149
|
+
unknown is charged its whole reservation. Telemetry stays on this machine.
|
|
150
|
+
|
|
151
|
+
## Platforms
|
|
152
|
+
|
|
153
|
+
Linux: supported. macOS: degraded (no `/proc` start times for liveness, no
|
|
154
|
+
bwrap: `isolated` refused). Windows: not supported (`fcntl`).
|
|
155
|
+
|
|
156
|
+
## Documentation
|
|
157
|
+
|
|
158
|
+
[Architecture](docs/architecture.md) · [Requirements matrix](docs/requirements.md) ·
|
|
159
|
+
[Benefits and measurements](docs/benefits.md) · [Benchmark protocol](docs/benchmark.md) ·
|
|
160
|
+
[Install](docs/install.md) · [Releasing](RELEASING.md) · [Contributing](CONTRIBUTING.md)
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# subtask
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.tedin7/subtask -->
|
|
4
|
+
|
|
5
|
+
A local delegation scheduler for coding agents. One MCP server (and a twin
|
|
6
|
+
CLI) lets Claude Code, Codex, Kimi Code or OpenCode start work in parallel,
|
|
7
|
+
wait for it with a single call, and read back a short summary with evidence.
|
|
8
|
+
It picks an engine that can take the job, books money and slots safely across
|
|
9
|
+
processes, and says honestly how isolated each run is.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
start_many([{task: "Fix the flaky test in tests/test_sync.py", repo: "/src/app", verify: "pytest -q tests/test_sync.py"},
|
|
13
|
+
{task: "List every caller of parse_date()", kind: "read", readonly: true, cwd: "/src/app"}])
|
|
14
|
+
-> st-4f2a91 started · sonnet-api · branch subtask/st-4f2a91 · sandbox
|
|
15
|
+
st-77c0de started · haiku-api · /src/app · readonly
|
|
16
|
+
wait(["st-4f2a91", "st-77c0de"]) # Claude Code shows: Subtask · 01:16 · 1/2 ended — st-4f2a91 running tests: pytest -q
|
|
17
|
+
-> st-4f2a91 done · sonnet-api · 6m · 0.412$ · verified · ...
|
|
18
|
+
## Summary (≤10 lines: the answer, files, commits, tests, what is left)
|
|
19
|
+
result("st-4f2a91", evidence=True) # JSON: base/head, changed files, observed commands, verify exit code, cost
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
- **Save the caller's context.** The worker's search, test runs and dead ends
|
|
25
|
+
stay in its own session; the caller reads about ten lines.
|
|
26
|
+
- **Few round trips.** `start_many` + one `wait` replace a polling loop.
|
|
27
|
+
- **Use whatever has room.** `engine="auto"` filters engines that can enforce
|
|
28
|
+
the requested isolation, are installed, fit the budget and have quota, then
|
|
29
|
+
ranks them with an explicit utility (`subtask routing explain`).
|
|
30
|
+
- **Money is a primitive.** Paid engines reserve their budget before any work,
|
|
31
|
+
under one lock with the slot; every reservation is closed exactly once.
|
|
32
|
+
- **Evidence, not claims.** `verify="..."` runs your check on the commit the
|
|
33
|
+
worker left; `result(evidence=True)` separates what subtask observed from
|
|
34
|
+
what the worker reported.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
The distribution is `subtask-mcp` (the PyPI name `subtask` is an unrelated
|
|
39
|
+
project). Full guide, per client, with native commands: [docs/install.md](docs/install.md).
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
uv tool install "git+https://github.com/tedin7/subtask@v0.2.3" # or: uv tool install /path/to/checkout
|
|
43
|
+
subtask install --client all # prints the plan for every detected client
|
|
44
|
+
subtask install --client all --yes # applies it: MCP server + skill
|
|
45
|
+
subtask doctor --client all # checks app, config entry and a real handshake
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Claude Code plugin: `claude plugin marketplace add tedin7/subtask` then
|
|
49
|
+
`claude plugin install subtask@subtask`. Codex: `codex plugin marketplace add tedin7/subtask`.
|
|
50
|
+
API engines read `ANTHROPIC_API_KEY` from `~/.config/subtask/env` (chmod 600).
|
|
51
|
+
|
|
52
|
+
## Engines
|
|
53
|
+
|
|
54
|
+
| engine | pays with | read-only | isolation it can enforce |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| `haiku-api`, `sonnet-api`, `opus-api` | API key, monthly cap | yes | sandbox (Claude Code OS sandbox) |
|
|
57
|
+
| `codex` | Codex login (quota from local session files) | yes | sandbox (`workspace-write`), isolated |
|
|
58
|
+
| `opencode-go` | opencode-go subscription | no | worktree, isolated |
|
|
59
|
+
| `kimi` | Kimi login | no | worktree, isolated |
|
|
60
|
+
| `dsh` | DeepSeek key, same cap (`subtask-mcp[dsh]`) | no | worktree |
|
|
61
|
+
| `opencode` | OpenCode default provider (manual only) | no | worktree, isolated |
|
|
62
|
+
|
|
63
|
+
Add an engine without touching the core: an `EngineAdapter` registered under the
|
|
64
|
+
`subtask.engines` entry point (see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
|
65
|
+
|
|
66
|
+
## Isolation, honestly
|
|
67
|
+
|
|
68
|
+
- `worktree` (default): a git worktree and branch per writing job. It prevents
|
|
69
|
+
collisions; it is **not** a host sandbox.
|
|
70
|
+
- `sandbox`: the engine's own sandbox. Same user: a guard rail.
|
|
71
|
+
- `isolated`: bubblewrap. Read-only system, writable worktree and git dir only,
|
|
72
|
+
a private HOME with only the engine's own state. Needs `bwrap`.
|
|
73
|
+
- `untrusted`: sandbox or stronger, and no network for the worker's commands.
|
|
74
|
+
|
|
75
|
+
A job that cannot get the policy it asked for is refused, never downgraded.
|
|
76
|
+
Output is redacted for common secret formats before it is saved or shown;
|
|
77
|
+
redaction is a second layer, not a guarantee. See [SECURITY.md](SECURITY.md).
|
|
78
|
+
|
|
79
|
+
## Tools (MCP) and CLI
|
|
80
|
+
|
|
81
|
+
| tool | what it does |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `start(task, kind, repo, cwd, files, readonly, engine, max_usd, effort, base, max_turns, policy, verify)` | start one job |
|
|
84
|
+
| `start_many(tasks)` | start several; partial success is listed per item |
|
|
85
|
+
| `wait(ids, timeout=300, any=False, since=None)` | block until they end; progress notifications while waiting; `since` cursor for clients without progress |
|
|
86
|
+
| `status(id)` / `result(id, full, evidence)` | one line / summary or full text or JSON envelope |
|
|
87
|
+
| `reply(id, message)` / `stop(id)` / `cleanup(id)` | continue / stop the work / remove a clean worktree (branch kept) |
|
|
88
|
+
| `engines()` | capacity per engine with source, freshness and confidence, plus spend |
|
|
89
|
+
| `step(title, path, how, decisions)` | an independent cheap model narrates the step from the repo's real state |
|
|
90
|
+
|
|
91
|
+
CLI only: `subtask doctor`, `gc [--apply]`, `du`, `roi`, `routing explain|calibrate`,
|
|
92
|
+
`watch ID...`, `install`, `uninstall`, and `--model`, `--no-sandbox`, `--untrusted` on start.
|
|
93
|
+
|
|
94
|
+
## Progress in the chat
|
|
95
|
+
|
|
96
|
+
| client | during a `wait` |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Claude Code 2.1 (TUI) | live line under the tool call, verified |
|
|
99
|
+
| OpenCode 2.0 | notifications received, not shown: use `wait(since=...)` |
|
|
100
|
+
| Kimi Code 2.1 | client does not request progress: `wait(since=...)` |
|
|
101
|
+
| Codex 0.162 | not yet verified (account at its usage limit) |
|
|
102
|
+
|
|
103
|
+
Details and evidence: [docs/progress-matrix.md](docs/progress-matrix.md).
|
|
104
|
+
|
|
105
|
+
## Settings
|
|
106
|
+
|
|
107
|
+
| variable | default |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `SUBTASK_HOME` | `~/.local/share/subtask` (jobs, worktrees, ledger, telemetry) |
|
|
110
|
+
| `SUBTASK_ENV_FILE` | `~/.config/subtask/env` |
|
|
111
|
+
| `SUBTASK_MONTHLY_CAP_USD` / `SUBTASK_DEFAULT_MAX_USD` / `SUBTASK_MAX_JOB_USD` | `180` / `2` / `20` |
|
|
112
|
+
| `SUBTASK_MAX_RUNNING` | `4` (global across all processes) |
|
|
113
|
+
| `SUBTASK_ROUTING` | `~/.config/subtask/routing.json` (weights, `"mode": "legacy"`) |
|
|
114
|
+
| `SUBTASK_JOB_RETENTION_DAYS` / `SUBTASK_TELEMETRY_DAYS` | `30` / `90` |
|
|
115
|
+
| `SUBTASK_PROGRESS_MIN_S` / `SUBTASK_PROGRESS_HEARTBEAT_S` | `2` / `10` |
|
|
116
|
+
|
|
117
|
+
Costs of API engines come from the SDK's estimate; a run whose real cost is
|
|
118
|
+
unknown is charged its whole reservation. Telemetry stays on this machine.
|
|
119
|
+
|
|
120
|
+
## Platforms
|
|
121
|
+
|
|
122
|
+
Linux: supported. macOS: degraded (no `/proc` start times for liveness, no
|
|
123
|
+
bwrap: `isolated` refused). Windows: not supported (`fcntl`).
|
|
124
|
+
|
|
125
|
+
## Documentation
|
|
126
|
+
|
|
127
|
+
[Architecture](docs/architecture.md) · [Requirements matrix](docs/requirements.md) ·
|
|
128
|
+
[Benefits and measurements](docs/benefits.md) · [Benchmark protocol](docs/benchmark.md) ·
|
|
129
|
+
[Install](docs/install.md) · [Releasing](RELEASING.md) · [Contributing](CONTRIBUTING.md)
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "subtask-mcp"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Background sub-agents for Claude Code, Codex, Kimi and OpenCode, over MCP or CLI, with a spend cap"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"License :: OSI Approved :: MIT License",
|
|
11
|
+
"Operating System :: POSIX",
|
|
12
|
+
"Operating System :: POSIX :: Linux",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["claude-agent-sdk>=0.2", "mcp>=1.28,<2"]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dsh = ["deepseek-harness-sdk==0.1.5rc1"]
|
|
23
|
+
dev = ["pytest>=8", "ruff>=0.6", "jsonschema>=4", "tqdm>=4"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/tedin7/subtask"
|
|
27
|
+
Repository = "https://github.com/tedin7/subtask"
|
|
28
|
+
Issues = "https://github.com/tedin7/subtask/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
subtask = "subtask.cli:run"
|
|
32
|
+
subtask-mcp = "subtask.fastserver:main"
|
|
33
|
+
subtask-mcp-fastmcp = "subtask.server:main"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=68"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
packages = ["subtask", "subtask.engines", "subtask.install"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.dynamic]
|
|
43
|
+
version = { attr = "subtask.__version__" }
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
subtask = ["tools.json", "skill/SKILL.md"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 110
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint.per-file-ignores]
|
|
52
|
+
"subtask/worker.py" = ["BLE001"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"] # bench/corpus/acceptance targets the frozen 3f731f6, not this tree
|
|
56
|
+
markers = [
|
|
57
|
+
"contract: offline adapter contract tests",
|
|
58
|
+
"live: needs real engines and budget (skipped by default)",
|
|
59
|
+
]
|
|
60
|
+
addopts = "-m 'not live'"
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Global admission: at most MAX_RUNNING active jobs across every CLI and MCP process.
|
|
2
|
+
|
|
3
|
+
The active index (<HOME>/active.json) is the source of truth for slots and is
|
|
4
|
+
only changed under <HOME>/admission.lock. It is rebuildable: `rebuild()` scans
|
|
5
|
+
job folders when the index is missing or corrupt. Entries:
|
|
6
|
+
|
|
7
|
+
{"job": id, "phase": "admitting"|"running", "owner": identity, "worker": identity|None, "since": ts}
|
|
8
|
+
|
|
9
|
+
Lock order: a job lock, then admission.lock, then the ledger lock. Nothing
|
|
10
|
+
slow (git, spawn, network) runs while admission.lock is held.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fcntl
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import config, procid
|
|
24
|
+
|
|
25
|
+
ADMIT_GRACE_S = 120 # an 'admitting' entry whose owner is alive but silent this long is stale
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _index() -> Path:
|
|
29
|
+
return config.HOME / "active.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _markers() -> Path:
|
|
33
|
+
"""One file per job between admission and its first status: survives a corrupt index."""
|
|
34
|
+
return config.HOME / "admitting"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def locked() -> Iterator[None]:
|
|
39
|
+
config.HOME.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
with (config.HOME / "admission.lock").open("a") as lock:
|
|
41
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
42
|
+
yield
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _valid_ident(ident: object) -> bool:
|
|
46
|
+
if not isinstance(ident, dict):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
return int(ident.get("pid")) > 0 # type: ignore[arg-type]
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _valid_entry(entry: object) -> bool:
|
|
55
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("job"), str):
|
|
56
|
+
return False
|
|
57
|
+
for key in ("owner", "worker"):
|
|
58
|
+
if entry.get(key) is not None and not _valid_ident(entry[key]):
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read() -> dict[str, dict] | None:
|
|
64
|
+
"""The index, or None when it is missing or corrupt (callers then rebuild)."""
|
|
65
|
+
try:
|
|
66
|
+
data = json.loads(_index().read_text())
|
|
67
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(data, dict) or not isinstance(data.get("jobs"), dict):
|
|
70
|
+
return None
|
|
71
|
+
return {k: v for k, v in data["jobs"].items() if _valid_entry(v)} # a bad entry is dropped, not fatal
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write(entries: dict[str, dict]) -> None:
|
|
75
|
+
path = _index()
|
|
76
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
77
|
+
tmp.write_text(json.dumps({"v": 1, "jobs": entries}, indent=1))
|
|
78
|
+
os.replace(tmp, path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mark(entry: dict) -> None:
|
|
82
|
+
_markers().mkdir(parents=True, exist_ok=True)
|
|
83
|
+
tmp = _markers() / f".{entry['job']}.{os.getpid()}.tmp"
|
|
84
|
+
tmp.write_text(json.dumps(entry))
|
|
85
|
+
os.replace(tmp, _markers() / f"{entry['job']}.json")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _unmark(job_id: str) -> None:
|
|
89
|
+
(_markers() / f"{job_id}.json").unlink(missing_ok=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def rebuild() -> dict[str, dict]:
|
|
93
|
+
"""Index from the job folders (queued/running) and the admission markers."""
|
|
94
|
+
entries: dict[str, dict] = {}
|
|
95
|
+
if _markers().is_dir():
|
|
96
|
+
for path in _markers().glob("*.json"):
|
|
97
|
+
try:
|
|
98
|
+
entry = json.loads(path.read_text())
|
|
99
|
+
except (OSError, json.JSONDecodeError):
|
|
100
|
+
continue
|
|
101
|
+
if _valid_entry(entry):
|
|
102
|
+
entries[entry["job"]] = entry
|
|
103
|
+
if config.JOBS.is_dir():
|
|
104
|
+
for folder in config.JOBS.iterdir():
|
|
105
|
+
try:
|
|
106
|
+
status = json.loads((folder / "status.json").read_text())
|
|
107
|
+
except (OSError, json.JSONDecodeError):
|
|
108
|
+
continue
|
|
109
|
+
if status.get("state") in ("queued", "running"):
|
|
110
|
+
worker = status.get("proc") or ({"pid": status["pid"]} if status.get("pid") else None)
|
|
111
|
+
entry = entries.get(folder.name) or {"job": folder.name, "owner": None}
|
|
112
|
+
entry.update(
|
|
113
|
+
phase="running" if worker else entry.get("phase", "running"),
|
|
114
|
+
since=status.get("updated", time.time()),
|
|
115
|
+
)
|
|
116
|
+
if _valid_ident(worker):
|
|
117
|
+
entry["worker"] = worker
|
|
118
|
+
entries[folder.name] = entry
|
|
119
|
+
return entries
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _live(entry: dict) -> bool:
|
|
123
|
+
"""False only with proof the entry no longer holds a slot."""
|
|
124
|
+
status, attempt = _job_state(entry["job"]), _job_attempt(entry["job"])
|
|
125
|
+
newer = entry.get("phase") == "admitting" and int(entry.get("attempt") or 1) > attempt
|
|
126
|
+
if status in ("done", "failed", "stopped") and not newer:
|
|
127
|
+
return False
|
|
128
|
+
if entry.get("phase") == "running":
|
|
129
|
+
worker = entry.get("worker")
|
|
130
|
+
if worker:
|
|
131
|
+
return procid.alive(worker) != procid.DEAD
|
|
132
|
+
return status in ("queued", "running", None)
|
|
133
|
+
owner = entry.get("owner")
|
|
134
|
+
verdict = procid.alive(owner) if owner else procid.UNKNOWN
|
|
135
|
+
if verdict == procid.DEAD:
|
|
136
|
+
return False
|
|
137
|
+
return not (verdict == procid.UNKNOWN and time.time() - float(entry.get("since") or 0) > ADMIT_GRACE_S)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _job_state(job_id: str) -> str | None:
|
|
141
|
+
try:
|
|
142
|
+
return json.loads((config.JOBS / job_id / "status.json").read_text()).get("state")
|
|
143
|
+
except (OSError, json.JSONDecodeError, AttributeError):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _job_attempt(job_id: str) -> int:
|
|
148
|
+
try:
|
|
149
|
+
return int(json.loads((config.JOBS / job_id / "status.json").read_text()).get("attempt") or 1)
|
|
150
|
+
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError):
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _drop(entry: dict) -> None:
|
|
155
|
+
"""An entry lost its slot. If its job never got a status, nothing ran: release its money."""
|
|
156
|
+
_unmark(entry["job"])
|
|
157
|
+
if entry.get("phase") == "admitting" and _job_state(entry["job"]) is None:
|
|
158
|
+
from . import ledger
|
|
159
|
+
|
|
160
|
+
for row in ledger.open_for(entry["job"]):
|
|
161
|
+
ledger.close(row["rid"], 0.0, "release")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _current() -> dict[str, dict]:
|
|
165
|
+
entries = _read()
|
|
166
|
+
if entries is None:
|
|
167
|
+
entries = rebuild()
|
|
168
|
+
live = {}
|
|
169
|
+
for key, entry in entries.items():
|
|
170
|
+
if _live(entry):
|
|
171
|
+
live[key] = entry
|
|
172
|
+
else:
|
|
173
|
+
_drop(entry)
|
|
174
|
+
return live
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def admit(job_id: str, limit: int | None = None, attempt: int = 1) -> str | None:
|
|
178
|
+
"""Take a slot for job_id. None on success, else the refusal reason. Caller holds nothing."""
|
|
179
|
+
with locked():
|
|
180
|
+
return admit_locked(job_id, limit, attempt)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def admit_locked(job_id: str, limit: int | None = None, attempt: int = 1) -> str | None:
|
|
184
|
+
"""Same as admit() for a caller already holding admission.lock."""
|
|
185
|
+
limit = config.MAX_RUNNING if limit is None else limit
|
|
186
|
+
entries = _current()
|
|
187
|
+
if len(entries) >= limit:
|
|
188
|
+
_write(entries)
|
|
189
|
+
return f"{limit} sub-tasks already running: use wait()"
|
|
190
|
+
entry = {
|
|
191
|
+
"job": job_id,
|
|
192
|
+
"attempt": attempt,
|
|
193
|
+
"phase": "admitting",
|
|
194
|
+
"owner": procid.identity(),
|
|
195
|
+
"worker": None,
|
|
196
|
+
"since": time.time(),
|
|
197
|
+
}
|
|
198
|
+
_mark(entry)
|
|
199
|
+
entries[job_id] = entry
|
|
200
|
+
_write(entries)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def running(job_id: str, worker: dict) -> None:
|
|
205
|
+
with locked():
|
|
206
|
+
entries = _read()
|
|
207
|
+
if entries is None:
|
|
208
|
+
entries = rebuild()
|
|
209
|
+
entries[job_id] = {
|
|
210
|
+
**entries.get(job_id, {"job": job_id}),
|
|
211
|
+
"phase": "running",
|
|
212
|
+
"worker": worker,
|
|
213
|
+
"since": time.time(),
|
|
214
|
+
}
|
|
215
|
+
_write(entries)
|
|
216
|
+
_unmark(job_id)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _mine(entry: dict) -> bool:
|
|
220
|
+
"""May the calling process free this entry? Its owner or worker may; anyone may once they are dead."""
|
|
221
|
+
me = procid.identity()
|
|
222
|
+
holder = entry.get("worker") if entry.get("phase") == "running" else entry.get("owner")
|
|
223
|
+
if not holder:
|
|
224
|
+
return True
|
|
225
|
+
if int(holder.get("pid", -1)) == me["pid"] and holder.get("start") in (None, me.get("start")):
|
|
226
|
+
return True
|
|
227
|
+
return procid.alive(holder) == procid.DEAD
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def release(job_id: str) -> bool:
|
|
231
|
+
"""Free the job's slot, unless a newer attempt (another live process) holds it now."""
|
|
232
|
+
with locked():
|
|
233
|
+
entries = _read()
|
|
234
|
+
if entries is None:
|
|
235
|
+
entries = rebuild()
|
|
236
|
+
entry = entries.get(job_id)
|
|
237
|
+
if entry is not None and not _mine(entry):
|
|
238
|
+
return False # a stale release (e.g. an old worker after a new reply): keep the slot
|
|
239
|
+
entries.pop(job_id, None)
|
|
240
|
+
_unmark(job_id)
|
|
241
|
+
_write(entries)
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def active() -> list[str]:
|
|
246
|
+
"""Jobs holding a slot right now. O(active), not O(history)."""
|
|
247
|
+
with locked():
|
|
248
|
+
entries = _current()
|
|
249
|
+
_write(entries)
|
|
250
|
+
return list(entries)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def count() -> int:
|
|
254
|
+
return len(active())
|