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.
Files changed (65) hide show
  1. subtask_mcp-0.2.3/LICENSE +21 -0
  2. subtask_mcp-0.2.3/PKG-INFO +164 -0
  3. subtask_mcp-0.2.3/README.md +133 -0
  4. subtask_mcp-0.2.3/pyproject.toml +60 -0
  5. subtask_mcp-0.2.3/setup.cfg +4 -0
  6. subtask_mcp-0.2.3/subtask/__init__.py +3 -0
  7. subtask_mcp-0.2.3/subtask/admission.py +254 -0
  8. subtask_mcp-0.2.3/subtask/capacity.py +90 -0
  9. subtask_mcp-0.2.3/subtask/cli.py +183 -0
  10. subtask_mcp-0.2.3/subtask/config.py +59 -0
  11. subtask_mcp-0.2.3/subtask/core.py +603 -0
  12. subtask_mcp-0.2.3/subtask/doctor.py +120 -0
  13. subtask_mcp-0.2.3/subtask/engines/__init__.py +1 -0
  14. subtask_mcp-0.2.3/subtask/engines/base.py +297 -0
  15. subtask_mcp-0.2.3/subtask/engines/claude_api.py +204 -0
  16. subtask_mcp-0.2.3/subtask/engines/codex.py +182 -0
  17. subtask_mcp-0.2.3/subtask/engines/demo.py +50 -0
  18. subtask_mcp-0.2.3/subtask/engines/dsh.py +96 -0
  19. subtask_mcp-0.2.3/subtask/engines/kimi.py +75 -0
  20. subtask_mcp-0.2.3/subtask/engines/opencode.py +87 -0
  21. subtask_mcp-0.2.3/subtask/engines/registry.py +124 -0
  22. subtask_mcp-0.2.3/subtask/events.py +105 -0
  23. subtask_mcp-0.2.3/subtask/evidence.py +103 -0
  24. subtask_mcp-0.2.3/subtask/fastserver.py +272 -0
  25. subtask_mcp-0.2.3/subtask/gc.py +99 -0
  26. subtask_mcp-0.2.3/subtask/install/__init__.py +1 -0
  27. subtask_mcp-0.2.3/subtask/install/clients.py +355 -0
  28. subtask_mcp-0.2.3/subtask/install/commands.py +122 -0
  29. subtask_mcp-0.2.3/subtask/install/doctor.py +112 -0
  30. subtask_mcp-0.2.3/subtask/install/fileops.py +69 -0
  31. subtask_mcp-0.2.3/subtask/install/installer.py +204 -0
  32. subtask_mcp-0.2.3/subtask/install/ownership.py +44 -0
  33. subtask_mcp-0.2.3/subtask/install/tomledit.py +44 -0
  34. subtask_mcp-0.2.3/subtask/jobs.py +256 -0
  35. subtask_mcp-0.2.3/subtask/ledger.py +268 -0
  36. subtask_mcp-0.2.3/subtask/narrate.py +134 -0
  37. subtask_mcp-0.2.3/subtask/policy.py +58 -0
  38. subtask_mcp-0.2.3/subtask/procid.py +97 -0
  39. subtask_mcp-0.2.3/subtask/progress.py +152 -0
  40. subtask_mcp-0.2.3/subtask/redact.py +61 -0
  41. subtask_mcp-0.2.3/subtask/roi.py +84 -0
  42. subtask_mcp-0.2.3/subtask/router.py +202 -0
  43. subtask_mcp-0.2.3/subtask/runners.py +99 -0
  44. subtask_mcp-0.2.3/subtask/schemacheck.py +131 -0
  45. subtask_mcp-0.2.3/subtask/server.py +192 -0
  46. subtask_mcp-0.2.3/subtask/skill/SKILL.md +96 -0
  47. subtask_mcp-0.2.3/subtask/telemetry.py +76 -0
  48. subtask_mcp-0.2.3/subtask/tools.json +237 -0
  49. subtask_mcp-0.2.3/subtask/worker.py +278 -0
  50. subtask_mcp-0.2.3/subtask_mcp.egg-info/PKG-INFO +164 -0
  51. subtask_mcp-0.2.3/subtask_mcp.egg-info/SOURCES.txt +63 -0
  52. subtask_mcp-0.2.3/subtask_mcp.egg-info/dependency_links.txt +1 -0
  53. subtask_mcp-0.2.3/subtask_mcp.egg-info/entry_points.txt +4 -0
  54. subtask_mcp-0.2.3/subtask_mcp.egg-info/requires.txt +11 -0
  55. subtask_mcp-0.2.3/subtask_mcp.egg-info/top_level.txt +1 -0
  56. subtask_mcp-0.2.3/tests/test_concurrency.py +275 -0
  57. subtask_mcp-0.2.3/tests/test_engines.py +154 -0
  58. subtask_mcp-0.2.3/tests/test_falsify.py +340 -0
  59. subtask_mcp-0.2.3/tests/test_independent_client.py +53 -0
  60. subtask_mcp-0.2.3/tests/test_install.py +366 -0
  61. subtask_mcp-0.2.3/tests/test_protocol.py +264 -0
  62. subtask_mcp-0.2.3/tests/test_routing_results.py +218 -0
  63. subtask_mcp-0.2.3/tests/test_schema.py +100 -0
  64. subtask_mcp-0.2.3/tests/test_security.py +182 -0
  65. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """subtask: background sub-agents for coding agents."""
2
+
3
+ __version__ = "0.2.3"
@@ -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())