subtask-mcp 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. subtask/__init__.py +3 -0
  2. subtask/admission.py +254 -0
  3. subtask/capacity.py +90 -0
  4. subtask/cli.py +183 -0
  5. subtask/config.py +59 -0
  6. subtask/core.py +603 -0
  7. subtask/doctor.py +120 -0
  8. subtask/engines/__init__.py +1 -0
  9. subtask/engines/base.py +297 -0
  10. subtask/engines/claude_api.py +204 -0
  11. subtask/engines/codex.py +182 -0
  12. subtask/engines/demo.py +50 -0
  13. subtask/engines/dsh.py +96 -0
  14. subtask/engines/kimi.py +75 -0
  15. subtask/engines/opencode.py +87 -0
  16. subtask/engines/registry.py +124 -0
  17. subtask/events.py +105 -0
  18. subtask/evidence.py +103 -0
  19. subtask/fastserver.py +272 -0
  20. subtask/gc.py +99 -0
  21. subtask/install/__init__.py +1 -0
  22. subtask/install/clients.py +355 -0
  23. subtask/install/commands.py +122 -0
  24. subtask/install/doctor.py +112 -0
  25. subtask/install/fileops.py +69 -0
  26. subtask/install/installer.py +204 -0
  27. subtask/install/ownership.py +44 -0
  28. subtask/install/tomledit.py +44 -0
  29. subtask/jobs.py +256 -0
  30. subtask/ledger.py +268 -0
  31. subtask/narrate.py +134 -0
  32. subtask/policy.py +58 -0
  33. subtask/procid.py +97 -0
  34. subtask/progress.py +152 -0
  35. subtask/redact.py +61 -0
  36. subtask/roi.py +84 -0
  37. subtask/router.py +202 -0
  38. subtask/runners.py +99 -0
  39. subtask/schemacheck.py +131 -0
  40. subtask/server.py +192 -0
  41. subtask/skill/SKILL.md +96 -0
  42. subtask/telemetry.py +76 -0
  43. subtask/tools.json +237 -0
  44. subtask/worker.py +278 -0
  45. subtask_mcp-0.2.3.dist-info/METADATA +164 -0
  46. subtask_mcp-0.2.3.dist-info/RECORD +50 -0
  47. subtask_mcp-0.2.3.dist-info/WHEEL +5 -0
  48. subtask_mcp-0.2.3.dist-info/entry_points.txt +4 -0
  49. subtask_mcp-0.2.3.dist-info/licenses/LICENSE +21 -0
  50. subtask_mcp-0.2.3.dist-info/top_level.txt +1 -0
subtask/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """subtask: background sub-agents for coding agents."""
2
+
3
+ __version__ = "0.2.3"
subtask/admission.py ADDED
@@ -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())
subtask/capacity.py ADDED
@@ -0,0 +1,90 @@
1
+ """How much room each engine has: value, unit, source, freshness, confidence.
2
+
3
+ Unknown stays unknown (value None). The router may use a prior for unknown
4
+ engines, and it says so. Quota exhaustion seen in a real run (e.g. Codex
5
+ "usage limit") is remembered until its reset time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ from . import config, jobs
13
+ from .engines import registry
14
+
15
+ CACHE_TTL_S = 120
16
+
17
+
18
+ def _cache():
19
+ return config.HOME / "capacity.json"
20
+
21
+
22
+ def _hints():
23
+ return config.HOME / "quota-hints.json"
24
+
25
+
26
+ def note_exhausted(engine: str, until: float | None, message: str) -> None:
27
+ """Record that a run hit the engine's quota (from the worker)."""
28
+ config.HOME.mkdir(parents=True, exist_ok=True)
29
+ hints = jobs.read_json(_hints())
30
+ hints[engine] = {"seen": time.time(), "until": until, "message": message[:200]}
31
+ jobs.write_json(_hints(), hints)
32
+ _cache().unlink(missing_ok=True)
33
+
34
+
35
+ def _hint(engine: str) -> dict | None:
36
+ hint = jobs.read_json(_hints()).get(engine)
37
+ if not hint:
38
+ return None
39
+ until = hint.get("until") or hint["seen"] + 3600 # unknown reset: assume an hour
40
+ return hint if until > time.time() else None
41
+
42
+
43
+ def snapshot(refresh: bool = False) -> dict[str, dict]:
44
+ cache = _cache()
45
+ if not refresh and cache.is_file() and time.time() - cache.stat().st_mtime < CACHE_TTL_S:
46
+ cached = jobs.read_json(cache)
47
+ if cached.get("v") == 2:
48
+ return cached["engines"]
49
+ out: dict[str, dict] = {}
50
+ for name, adapter in registry.all_engines().items():
51
+ try:
52
+ reading = adapter.capacity().as_dict()
53
+ except Exception as exc: # noqa: BLE001 - one broken source must not hide the others
54
+ reading = {"value": None, "unit": "pct_free", "source": "error", "ts": time.time(),
55
+ "confidence": "none", "note": f"{type(exc).__name__}: {exc}"[:120]} # fmt: skip
56
+ hint = _hint(name)
57
+ if hint:
58
+ reading = {**reading, "value": 0.0, "confidence": "high", "source": "last run: quota exhausted",
59
+ "note": hint["message"], "ts": hint["seen"], "resets_at": hint.get("until")} # fmt: skip
60
+ out[name] = reading
61
+ config.HOME.mkdir(parents=True, exist_ok=True)
62
+ jobs.write_json(cache, {"v": 2, "engines": out}) # atomic: parallel readers never see half a file
63
+ return out
64
+
65
+
66
+ def report() -> str:
67
+ lines = []
68
+ for engine, info in snapshot(refresh=True).items():
69
+ value = "?" if info["value"] is None else f"{info['value']:.0f}%"
70
+ age = int(time.time() - float(info.get("ts") or time.time()))
71
+ lines.append(
72
+ f"{engine:12} free {value:>4} · conf {info['confidence']:6} · age {age}s · {info['note']}"[:150]
73
+ )
74
+ return "\n".join(lines)
75
+
76
+
77
+ def pick(kind: str = "code", max_usd: float | None = None, readonly: bool = False) -> tuple[str, str]:
78
+ """v0 API, kept for callers: (engine, reason). The router does the work."""
79
+ from . import policy, router
80
+
81
+ engine, reason, _ = router.pick(kind, max_usd, policy.demand(readonly=readonly))
82
+ return engine, reason
83
+
84
+
85
+ def __getattr__(name: str):
86
+ if name == "KINDS": # v0 API: the kind table now lives in the router
87
+ from .router import KINDS
88
+
89
+ return KINDS
90
+ raise AttributeError(name)
subtask/cli.py ADDED
@@ -0,0 +1,183 @@
1
+ """Command line twin of the MCP tools, for agents or humans without MCP.
2
+
3
+ subtask start "brief" [--engine auto] [--kind code] [--repo PATH] [--readonly] [--max-usd 2]
4
+ subtask wait ID [ID...] [--timeout 50]
5
+ subtask status [ID] | result ID [--full|--evidence] | reply ID "msg" | stop ID | cleanup ID | spend | engines
6
+ subtask install|uninstall --client claude|codex|kimi|opencode|all [--yes]
7
+ subtask doctor [--json] [--client X] | gc [--days 30] [--apply] | du | roi | routing explain|calibrate | watch ID...
8
+
9
+ Differences from the MCP tools: the CLI also has --model, --no-sandbox (same as
10
+ --policy worktree), --untrusted, spend, steps, install, doctor, gc, du, roi,
11
+ routing and watch. MCP `engines` returns capacity plus spend in one call.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+
19
+ from . import core
20
+ from .install import commands as install_commands
21
+
22
+
23
+ def main() -> int:
24
+ p = argparse.ArgumentParser(
25
+ prog="subtask", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
26
+ )
27
+ sub = p.add_subparsers(dest="cmd", required=True)
28
+ s = sub.add_parser("start")
29
+ s.add_argument("task", help="brief, or - to read it from stdin")
30
+ s.add_argument("--engine", default="auto")
31
+ s.add_argument("--kind", default="code", choices=["read", "code", "review", "hard"])
32
+ s.add_argument("--repo")
33
+ s.add_argument("--cwd")
34
+ s.add_argument("--base", default="HEAD")
35
+ s.add_argument("--readonly", action="store_true")
36
+ s.add_argument("--max-usd", type=float)
37
+ s.add_argument("--max-turns", type=int, default=60)
38
+ s.add_argument("--effort", default="medium")
39
+ s.add_argument("--model")
40
+ s.add_argument("--no-sandbox", action="store_true")
41
+ s.add_argument("--file", action="append", dest="files", help="path the agent reads first")
42
+ s.add_argument("--policy", choices=["worktree", "sandbox", "isolated", "untrusted"])
43
+ s.add_argument("--untrusted", action="store_true")
44
+ s.add_argument("--verify", help="command subtask runs itself on the result (exit 0 = verified)")
45
+ w = sub.add_parser("wait")
46
+ w.add_argument("ids", nargs="+")
47
+ w.add_argument("--timeout", type=int, default=300)
48
+ w.add_argument("--no-results", action="store_true")
49
+ w.add_argument("--any", action="store_true", help="return when the first job ends")
50
+ w.add_argument("--since", help="cursor from a previous wait ('' to start): adds what changed")
51
+ st = sub.add_parser("status")
52
+ st.add_argument("id", nargs="?")
53
+ r = sub.add_parser("result")
54
+ r.add_argument("id")
55
+ r.add_argument("--full", action="store_true")
56
+ r.add_argument("--evidence", action="store_true", help="JSON envelope with sources")
57
+ rp = sub.add_parser("reply")
58
+ rp.add_argument("id")
59
+ rp.add_argument("message")
60
+ for name in ("stop", "cleanup"):
61
+ sub.add_parser(name).add_argument("id")
62
+ sp = sub.add_parser("step")
63
+ sp.add_argument("title")
64
+ sp.add_argument("--path", required=True)
65
+ sp.add_argument("--how", required=True)
66
+ sp.add_argument("--decision", action="append", dest="decisions")
67
+ sub.add_parser("steps")
68
+ sub.add_parser("spend")
69
+ sub.add_parser("engines")
70
+ install_commands.add_parsers(sub)
71
+ g = sub.add_parser("gc")
72
+ g.add_argument("--days", type=int, default=30)
73
+ g.add_argument("--apply", action="store_true")
74
+ sub.add_parser("du")
75
+ sub.add_parser("roi")
76
+ ro = sub.add_parser("routing")
77
+ ro.add_argument("action", choices=["explain", "calibrate"])
78
+ ro.add_argument("--kind", default="code", choices=["read", "code", "review", "hard"])
79
+ ro.add_argument("--policy", choices=["worktree", "sandbox", "isolated", "untrusted"])
80
+ ro.add_argument("--readonly", action="store_true")
81
+ wa = sub.add_parser("watch", help="terminal view of job progress (diagnostic, not the chat)")
82
+ wa.add_argument("ids", nargs="+")
83
+ wa.add_argument("--interval", type=float, default=2.0)
84
+ a = p.parse_args()
85
+
86
+ if a.cmd in ("install", "uninstall") or (a.cmd == "doctor" and a.client):
87
+ return install_commands.run(a)
88
+
89
+ if a.cmd == "start":
90
+ task = sys.stdin.read() if a.task == "-" else a.task
91
+ out = core.start(
92
+ task,
93
+ a.engine,
94
+ a.repo,
95
+ a.cwd,
96
+ a.base,
97
+ a.readonly,
98
+ a.max_usd,
99
+ a.max_turns,
100
+ a.effort,
101
+ a.model,
102
+ not a.no_sandbox,
103
+ a.kind,
104
+ a.files,
105
+ policy=a.policy,
106
+ verify=a.verify,
107
+ untrusted=a.untrusted,
108
+ )
109
+ elif a.cmd == "wait":
110
+ out = core.wait(a.ids, a.timeout, a.any, not a.no_results, since=a.since)
111
+ elif a.cmd == "status":
112
+ out = core.status(a.id)
113
+ elif a.cmd == "result":
114
+ out = core.result(a.id, a.full, a.evidence)
115
+ elif a.cmd == "reply":
116
+ out = core.reply(a.id, a.message)
117
+ elif a.cmd == "step":
118
+ out = core.step(a.title, a.path, a.how, a.decisions)
119
+ elif a.cmd == "steps":
120
+ out = core.steps()
121
+ elif a.cmd == "stop":
122
+ out = core.stop(a.id)
123
+ elif a.cmd == "cleanup":
124
+ out = core.cleanup(a.id)
125
+ elif a.cmd == "engines":
126
+ out = core.engines()
127
+ elif a.cmd == "doctor":
128
+ from . import doctor
129
+
130
+ out = doctor.report(a.json)
131
+ elif a.cmd == "gc":
132
+ from . import gc
133
+
134
+ out = gc.run(a.days, a.apply)
135
+ elif a.cmd == "du":
136
+ from . import gc
137
+
138
+ out = "\n".join(f"{k}: {v / 1e6:.1f} MB" for k, v in gc.du().items())
139
+ elif a.cmd == "roi":
140
+ from . import roi
141
+
142
+ out = roi.report()
143
+ elif a.cmd == "routing":
144
+ from . import policy, router
145
+
146
+ wanted = policy.demand(a.readonly, a.policy)
147
+ out = router.explain(a.kind, None, wanted) if a.action == "explain" else router.calibrate()
148
+ elif a.cmd == "watch":
149
+ return _watch(a.ids, a.interval)
150
+ else:
151
+ out = core.spend()
152
+ print(out)
153
+ return 0
154
+
155
+
156
+ def _watch(ids: list[str], interval: float) -> int:
157
+ import time
158
+
159
+ from . import jobs
160
+ from .progress import Reporter
161
+
162
+ reporter = Reporter(lambda _v, message, _t: print(message, flush=True), ids, min_interval=interval)
163
+ try:
164
+ while True:
165
+ reporter.poll()
166
+ if all(jobs.status(j).get("state") in jobs.FINAL for j in ids):
167
+ reporter.phase(reporter.summary(), force=True)
168
+ return 0
169
+ time.sleep(0.5)
170
+ except KeyboardInterrupt:
171
+ return 130
172
+
173
+
174
+ def run() -> int:
175
+ try:
176
+ return main()
177
+ except ValueError as exc: # bad id or argument: one line, no traceback
178
+ print(f"error: {exc}", file=sys.stderr)
179
+ return 2
180
+
181
+
182
+ if __name__ == "__main__":
183
+ sys.exit(run())
subtask/config.py ADDED
@@ -0,0 +1,59 @@
1
+ """Settings, all overridable from the environment. No personal data lives here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ from pathlib import Path
8
+
9
+ HOME = Path(os.environ.get("SUBTASK_HOME", Path.home() / ".local" / "share" / "subtask"))
10
+ JOBS = HOME / "jobs"
11
+ WORKTREES = HOME / "worktrees"
12
+ LEDGER = HOME / "ledger.jsonl"
13
+
14
+ # KEY=VALUE file holding ANTHROPIC_API_KEY (and optionally ANTHROPIC_WORKSPACE_ID).
15
+ # Keep it chmod 600; it is read by the worker only, never returned by a tool.
16
+ ENV_FILE = Path(os.environ.get("SUBTASK_ENV_FILE", Path.home() / ".config" / "subtask" / "env"))
17
+
18
+
19
+ def _money(name: str, default: str) -> float:
20
+ try:
21
+ value = float(os.environ.get(name, default))
22
+ except ValueError:
23
+ value = float(default)
24
+ return value if math.isfinite(value) and value >= 0 else float(default)
25
+
26
+
27
+ MONTHLY_CAP_USD = _money("SUBTASK_MONTHLY_CAP_USD", "180")
28
+ DEFAULT_MAX_USD = _money("SUBTASK_DEFAULT_MAX_USD", "2")
29
+ MAX_JOB_USD = _money("SUBTASK_MAX_JOB_USD", "20")
30
+ MAX_RUNNING = int(_money("SUBTASK_MAX_RUNNING", "4")) or 4
31
+ SUMMARY_LINES = 12
32
+
33
+ # DeepSeek (engine "dsh"): key file and list price in USD per million tokens.
34
+ # Peak rates, checked 2026-09-10; override if the price list changes.
35
+ DSH_KEY_FILE = Path(os.environ.get("SUBTASK_DSH_KEY_FILE", Path.home() / ".config" / "deepseek" / "api-key"))
36
+ DSH_INPUT_USD_M = _money("SUBTASK_DSH_INPUT_USD_M", "0.30")
37
+ DSH_CACHED_USD_M = _money("SUBTASK_DSH_CACHED_USD_M", "0.006")
38
+ DSH_OUTPUT_USD_M = _money("SUBTASK_DSH_OUTPUT_USD_M", "1.20")
39
+
40
+
41
+ def deepseek_key() -> str:
42
+ """DEEPSEEK_API_KEY from the env file, else the key file. Never from the environment."""
43
+ key = load_secrets().get("DEEPSEEK_API_KEY", "")
44
+ if not key and DSH_KEY_FILE.is_file():
45
+ key = DSH_KEY_FILE.read_text().strip()
46
+ return key
47
+
48
+
49
+ def load_secrets() -> dict[str, str]:
50
+ """Read the env file. The key is never taken from the environment."""
51
+ values: dict[str, str] = {}
52
+ if ENV_FILE.is_file():
53
+ for line in ENV_FILE.read_text().splitlines():
54
+ line = line.strip()
55
+ if not line or line.startswith("#") or "=" not in line:
56
+ continue
57
+ key, _, value = line.removeprefix("export ").partition("=")
58
+ values[key.strip()] = value.strip().strip('"').strip("'")
59
+ return values