threadkeeper 0.4.0__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.
- threadkeeper/__init__.py +8 -0
- threadkeeper/_mcp.py +6 -0
- threadkeeper/_setup.py +299 -0
- threadkeeper/adapters/__init__.py +40 -0
- threadkeeper/adapters/_hook_helpers.py +72 -0
- threadkeeper/adapters/base.py +152 -0
- threadkeeper/adapters/claude_code.py +178 -0
- threadkeeper/adapters/claude_desktop.py +128 -0
- threadkeeper/adapters/codex.py +259 -0
- threadkeeper/adapters/copilot.py +195 -0
- threadkeeper/adapters/gemini.py +169 -0
- threadkeeper/adapters/vscode.py +144 -0
- threadkeeper/brief.py +735 -0
- threadkeeper/config.py +216 -0
- threadkeeper/curator.py +390 -0
- threadkeeper/db.py +474 -0
- threadkeeper/embeddings.py +232 -0
- threadkeeper/extract_daemon.py +125 -0
- threadkeeper/helpers.py +101 -0
- threadkeeper/i18n.py +342 -0
- threadkeeper/identity.py +237 -0
- threadkeeper/ingest.py +507 -0
- threadkeeper/lessons.py +170 -0
- threadkeeper/nudges.py +257 -0
- threadkeeper/process_health.py +202 -0
- threadkeeper/review_prompts.py +207 -0
- threadkeeper/search_proxy.py +160 -0
- threadkeeper/server.py +55 -0
- threadkeeper/shadow_review.py +358 -0
- threadkeeper/skill_watcher.py +96 -0
- threadkeeper/spawn_budget.py +246 -0
- threadkeeper/tools/__init__.py +2 -0
- threadkeeper/tools/concepts.py +111 -0
- threadkeeper/tools/consolidate.py +222 -0
- threadkeeper/tools/core_memory.py +109 -0
- threadkeeper/tools/correlation.py +116 -0
- threadkeeper/tools/curator.py +121 -0
- threadkeeper/tools/dialectic.py +359 -0
- threadkeeper/tools/dialog.py +131 -0
- threadkeeper/tools/distill.py +184 -0
- threadkeeper/tools/extract.py +411 -0
- threadkeeper/tools/graph.py +183 -0
- threadkeeper/tools/invariants.py +177 -0
- threadkeeper/tools/lessons.py +110 -0
- threadkeeper/tools/missed_spawns.py +142 -0
- threadkeeper/tools/peers.py +579 -0
- threadkeeper/tools/pickup.py +148 -0
- threadkeeper/tools/probes.py +251 -0
- threadkeeper/tools/process_health.py +90 -0
- threadkeeper/tools/session.py +34 -0
- threadkeeper/tools/shadow_review.py +106 -0
- threadkeeper/tools/skills.py +856 -0
- threadkeeper/tools/spawn.py +871 -0
- threadkeeper/tools/style.py +44 -0
- threadkeeper/tools/threads.py +299 -0
- threadkeeper-0.4.0.dist-info/METADATA +351 -0
- threadkeeper-0.4.0.dist-info/RECORD +61 -0
- threadkeeper-0.4.0.dist-info/WHEEL +5 -0
- threadkeeper-0.4.0.dist-info/entry_points.txt +2 -0
- threadkeeper-0.4.0.dist-info/licenses/LICENSE +21 -0
- threadkeeper-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
"""Child-session spawning and task management.
|
|
2
|
+
|
|
3
|
+
Provides the `spawn`, `tournament`, `tasks`, `task_kill`, `task_logs` MCP
|
|
4
|
+
tools, plus the supporting helpers (`_claude_bin`, `_resolve_spawned_cid`,
|
|
5
|
+
`_visible_task_status`, `_refresh_tasks`) and the `ROLE_PROMPTS` library
|
|
6
|
+
that defines cognitive stances a spawned child can adopt.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import signal as _sig
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
import secrets
|
|
17
|
+
import time
|
|
18
|
+
import json as _json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from .._mcp import mcp
|
|
23
|
+
from ..db import get_db
|
|
24
|
+
from ..config import TASK_LOG_DIR, CLAUDE_PROJECTS_DIR
|
|
25
|
+
from ..helpers import fmt_age, q, alive
|
|
26
|
+
from .. import identity # noqa: F401 (kept for future identity.* attr access)
|
|
27
|
+
from ..identity import _ensure_session, _detect_self_cid, _emit
|
|
28
|
+
from ..ingest import _parse_ts
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _claude_bin() -> Optional[str]:
|
|
32
|
+
"""Find claude CLI. Prefer CLAUDE_CODE_EXECPATH, then PATH, then known
|
|
33
|
+
install locations. Returns None if not found."""
|
|
34
|
+
p = os.environ.get("CLAUDE_CODE_EXECPATH")
|
|
35
|
+
if p and Path(p).exists():
|
|
36
|
+
return p
|
|
37
|
+
found = shutil.which("claude")
|
|
38
|
+
if found:
|
|
39
|
+
return found
|
|
40
|
+
for cand in (
|
|
41
|
+
Path.home() / ".local/bin/claude",
|
|
42
|
+
Path("/opt/homebrew/bin/claude"),
|
|
43
|
+
Path("/usr/local/bin/claude"),
|
|
44
|
+
):
|
|
45
|
+
if cand.exists():
|
|
46
|
+
return str(cand)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_spawned_cid(conn: sqlite3.Connection, task_id: str,
|
|
51
|
+
cwd: str, started_at: int) -> Optional[str]:
|
|
52
|
+
"""Find the jsonl created by this spawned child, if it has appeared.
|
|
53
|
+
Heuristic: in the project dir for `cwd`, look for jsonl files whose
|
|
54
|
+
earliest message timestamp is within [started_at-2, started_at+120]."""
|
|
55
|
+
# cwd starts with '/'; replacing yields '-Users-…' (single leading dash).
|
|
56
|
+
# Prior code added another dash, breaking the lookup.
|
|
57
|
+
slug = cwd.replace("/", "-")
|
|
58
|
+
project_dir = CLAUDE_PROJECTS_DIR / slug
|
|
59
|
+
if not project_dir.exists():
|
|
60
|
+
return None
|
|
61
|
+
# exclude any cid already linked to another task in this batch
|
|
62
|
+
used = set(
|
|
63
|
+
r["spawned_cid"] for r in conn.execute(
|
|
64
|
+
"SELECT spawned_cid FROM tasks WHERE spawned_cid IS NOT NULL"
|
|
65
|
+
).fetchall()
|
|
66
|
+
)
|
|
67
|
+
candidates: list[tuple[float, str]] = []
|
|
68
|
+
for p in project_dir.glob("*.jsonl"):
|
|
69
|
+
# subagent jsonl files (spawned by the child via Task tool) have
|
|
70
|
+
# 'agent-' prefix; they're not the main session jsonl.
|
|
71
|
+
if p.stem.startswith("agent-"):
|
|
72
|
+
continue
|
|
73
|
+
try:
|
|
74
|
+
st = p.stat()
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
# use mtime as a coarse filter — child writes start within seconds
|
|
78
|
+
# of spawn. ctime alone is unreliable across filesystems.
|
|
79
|
+
if st.st_mtime < started_at - 2 or st.st_mtime > started_at + 600:
|
|
80
|
+
continue
|
|
81
|
+
cid = p.stem
|
|
82
|
+
if cid in used:
|
|
83
|
+
continue
|
|
84
|
+
# peek first non-meta line for timestamp
|
|
85
|
+
try:
|
|
86
|
+
with p.open("r", encoding="utf-8", errors="replace") as f:
|
|
87
|
+
for line in f:
|
|
88
|
+
line = line.strip()
|
|
89
|
+
if not line:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
obj = _json.loads(line)
|
|
93
|
+
except _json.JSONDecodeError:
|
|
94
|
+
continue
|
|
95
|
+
ts = obj.get("timestamp")
|
|
96
|
+
if not ts:
|
|
97
|
+
continue
|
|
98
|
+
first_ts = _parse_ts(ts)
|
|
99
|
+
if started_at - 2 <= first_ts <= started_at + 600:
|
|
100
|
+
candidates.append((abs(first_ts - started_at), cid))
|
|
101
|
+
break
|
|
102
|
+
except OSError:
|
|
103
|
+
continue
|
|
104
|
+
if not candidates:
|
|
105
|
+
return None
|
|
106
|
+
candidates.sort()
|
|
107
|
+
return candidates[0][1]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _visible_task_status(cwd: str, cid: Optional[str],
|
|
111
|
+
started_at: int, idle_s: int = 30) -> tuple[str, Optional[int]]:
|
|
112
|
+
"""For visible (pid=0) tasks: infer status from the child's jsonl mtime.
|
|
113
|
+
Returns (status, ended_at_guess). status ∈ {'running','idle','no_jsonl'}.
|
|
114
|
+
`idle_s` controls how long since last jsonl write counts as 'done'."""
|
|
115
|
+
if not cid:
|
|
116
|
+
return ("no_cid", None)
|
|
117
|
+
slug = cwd.replace("/", "-")
|
|
118
|
+
jp = CLAUDE_PROJECTS_DIR / slug / f"{cid}.jsonl"
|
|
119
|
+
if not jp.exists():
|
|
120
|
+
return ("no_jsonl", None)
|
|
121
|
+
try:
|
|
122
|
+
m = int(jp.stat().st_mtime)
|
|
123
|
+
except OSError:
|
|
124
|
+
return ("no_jsonl", None)
|
|
125
|
+
now_t = int(time.time())
|
|
126
|
+
if now_t - m < idle_s:
|
|
127
|
+
return ("running", None)
|
|
128
|
+
return ("idle", m)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _refresh_tasks(conn: sqlite3.Connection) -> None:
|
|
132
|
+
"""Update running tasks: detect process exit (or jsonl idle for visible
|
|
133
|
+
tasks), link spawned_cid where possible. Cheap; safe to call before any
|
|
134
|
+
task-listing read."""
|
|
135
|
+
now_t = int(time.time())
|
|
136
|
+
rows = conn.execute(
|
|
137
|
+
"SELECT id, pid, cwd, started_at, spawned_cid, ended_at FROM tasks "
|
|
138
|
+
"WHERE ended_at IS NULL OR spawned_cid IS NULL "
|
|
139
|
+
"ORDER BY started_at DESC LIMIT 50"
|
|
140
|
+
).fetchall()
|
|
141
|
+
for t in rows:
|
|
142
|
+
updates: list[tuple[str, object]] = []
|
|
143
|
+
if t["ended_at"] is None:
|
|
144
|
+
if t["pid"] and t["pid"] > 0:
|
|
145
|
+
if not alive(t["pid"]):
|
|
146
|
+
updates.append(("ended_at", now_t))
|
|
147
|
+
else:
|
|
148
|
+
# visible task — infer from jsonl idleness
|
|
149
|
+
status, end_guess = _visible_task_status(
|
|
150
|
+
t["cwd"], t["spawned_cid"], t["started_at"]
|
|
151
|
+
)
|
|
152
|
+
if status == "idle" and end_guess:
|
|
153
|
+
updates.append(("ended_at", end_guess))
|
|
154
|
+
if t["spawned_cid"] is None:
|
|
155
|
+
cid = _resolve_spawned_cid(conn, t["id"], t["cwd"], t["started_at"])
|
|
156
|
+
if cid:
|
|
157
|
+
updates.append(("spawned_cid", cid))
|
|
158
|
+
if updates:
|
|
159
|
+
sets = ", ".join(f"{k}=?" for k, _ in updates)
|
|
160
|
+
params = [v for _, v in updates] + [t["id"]]
|
|
161
|
+
conn.execute(f"UPDATE tasks SET {sets} WHERE id=?", params)
|
|
162
|
+
if rows:
|
|
163
|
+
conn.commit()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Role library: predefined cognitive stances a spawned child can adopt.
|
|
167
|
+
# Each entry = a system-prompt addendum that nudges the child toward a
|
|
168
|
+
# specific mode of thinking. Used by spawn(role=...) and tournament().
|
|
169
|
+
ROLE_PROMPTS: dict[str, str] = {
|
|
170
|
+
"skeptic":
|
|
171
|
+
"Stance: skeptic. Find weak points, question assumptions, hunt for "
|
|
172
|
+
"where the obvious answer fails. Don't propose solutions — only "
|
|
173
|
+
"puncture. Output: 3-7 bullet criticisms, ranked by severity.",
|
|
174
|
+
"generator":
|
|
175
|
+
"Stance: generator. Produce as many distinct angles/options as you "
|
|
176
|
+
"can, even half-baked or weird. Quantity over quality. Don't "
|
|
177
|
+
"self-filter or critique. Output: numbered list of 5-15 ideas.",
|
|
178
|
+
"critic":
|
|
179
|
+
"Stance: critic. Read what others (parent, siblings via inbox/"
|
|
180
|
+
"dialog_search) have proposed and rank by correctness, simplicity, "
|
|
181
|
+
"risk. Output: top-3 with reasoning + 1 'avoid this' anti-pick.",
|
|
182
|
+
"archivist":
|
|
183
|
+
"Stance: archivist. Search the shared memory (search/dialog_search) "
|
|
184
|
+
"for past similar problems and their outcomes. Don't invent — "
|
|
185
|
+
"transplant. Output: 2-5 relevant precedents with citations to "
|
|
186
|
+
"thread/note ids and the lesson each carries.",
|
|
187
|
+
"synthesizer":
|
|
188
|
+
"Stance: synthesizer. Pull diverse positions from peers (inbox/"
|
|
189
|
+
"dialog_search) and fuse them into one coherent stance — shorter "
|
|
190
|
+
"and crisper than the sum. Output: a single paragraph that "
|
|
191
|
+
"supersedes the inputs.",
|
|
192
|
+
"explorer":
|
|
193
|
+
"Stance: explorer. Apply non-obvious analogies, port the problem "
|
|
194
|
+
"to another domain, try the inverse direction. Heuristic: 'what if "
|
|
195
|
+
"the opposite'. Output: 2-3 reframes that change the question, not "
|
|
196
|
+
"just the answer.",
|
|
197
|
+
"executor":
|
|
198
|
+
"Stance: executor. Take the most concrete actionable step that "
|
|
199
|
+
"advances the task. No analysis paralysis. Output: the single "
|
|
200
|
+
"specific next action, in imperative form, ready to perform.",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _build_slim_mcp_config(task_id: str) -> Optional[Path]:
|
|
205
|
+
"""Write a minimal MCP config containing ONLY thread-keeper, so the
|
|
206
|
+
spawned child doesn't load every other MCP server (context7, figma,
|
|
207
|
+
stitch, etc.). Pair with --strict-mcp-config on the CLI.
|
|
208
|
+
|
|
209
|
+
Resolution: prefer the user's ~/.claude.json `thread-keeper` entry
|
|
210
|
+
(matches their actual install). Fall back to a synthesized config
|
|
211
|
+
based on the running Python interpreter and package location.
|
|
212
|
+
|
|
213
|
+
Returns the path to the slim config file, or None if neither path
|
|
214
|
+
can produce a valid entry (caller should fall back to full config).
|
|
215
|
+
"""
|
|
216
|
+
slim_dir = TASK_LOG_DIR
|
|
217
|
+
slim_dir.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
slim_path = slim_dir / f"slim-mcp-{task_id}.json"
|
|
219
|
+
mp_entry = None
|
|
220
|
+
claude_json = Path.home() / ".claude.json"
|
|
221
|
+
if claude_json.exists():
|
|
222
|
+
try:
|
|
223
|
+
data = _json.loads(claude_json.read_text(encoding="utf-8"))
|
|
224
|
+
mp_entry = (data.get("mcpServers") or {}).get("thread-keeper")
|
|
225
|
+
except (OSError, _json.JSONDecodeError):
|
|
226
|
+
mp_entry = None
|
|
227
|
+
if not mp_entry:
|
|
228
|
+
# Synthesize from current runtime — same interpreter, same package.
|
|
229
|
+
pkg_root = str(Path(__file__).resolve().parent.parent.parent)
|
|
230
|
+
mp_entry = {
|
|
231
|
+
"type": "stdio",
|
|
232
|
+
"command": sys.executable,
|
|
233
|
+
"args": ["-m", "threadkeeper.server"],
|
|
234
|
+
"env": {
|
|
235
|
+
"PYTHONPATH": pkg_root,
|
|
236
|
+
"THREADKEEPER_TZ": os.environ.get(
|
|
237
|
+
"THREADKEEPER_TZ", "UTC"
|
|
238
|
+
),
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
try:
|
|
242
|
+
slim_path.write_text(
|
|
243
|
+
_json.dumps({"mcpServers": {"thread-keeper": mp_entry}},
|
|
244
|
+
indent=2),
|
|
245
|
+
encoding="utf-8",
|
|
246
|
+
)
|
|
247
|
+
except OSError:
|
|
248
|
+
return None
|
|
249
|
+
return slim_path
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@mcp.tool()
|
|
253
|
+
def spawn(prompt: str, cwd: str = "", append_system: str = "",
|
|
254
|
+
model: str = "", effort: str = "",
|
|
255
|
+
permission_mode: str = "auto",
|
|
256
|
+
extra_allowed_tools: str = "",
|
|
257
|
+
capture_output: bool = True,
|
|
258
|
+
visible: bool = True,
|
|
259
|
+
role: str = "",
|
|
260
|
+
write_origin: str = "",
|
|
261
|
+
slim: bool = True) -> str:
|
|
262
|
+
"""Launch a NEW claude session in parallel — your primary parallelism primitive.
|
|
263
|
+
|
|
264
|
+
REACH FOR THIS WHEN:
|
|
265
|
+
- you catch yourself about to do N independent things sequentially
|
|
266
|
+
(give each to its own child; collect summaries via inbox/wait)
|
|
267
|
+
- a task is long-running and you don't need to babysit
|
|
268
|
+
(build, ingest, scrape, deep research) — spawn(visible=False), check task_logs later
|
|
269
|
+
- multiple angles benefit from triangulation
|
|
270
|
+
(3 children with different role= , then vote_distill / consolidate)
|
|
271
|
+
- user signals decomposition via trigger phrases — see
|
|
272
|
+
threadkeeper.i18n.SPAWN_TRIGGER_PHRASE_EXAMPLES for the bilingual list
|
|
273
|
+
- a thread is stale and unblocks if someone just *does* it
|
|
274
|
+
(pickup_candidates → spawn child with the plan)
|
|
275
|
+
- you need a fresh context window without polluting your own
|
|
276
|
+
(e.g. the user's question pulls in topics that would bloat this convo)
|
|
277
|
+
|
|
278
|
+
DEFAULT TO SPAWNING when work decomposes. Sequential is the slow path —
|
|
279
|
+
every minute the parent thinks step-by-step is a minute the children
|
|
280
|
+
aren't doing anything. The only reason NOT to spawn is tight
|
|
281
|
+
back-and-forth coupling (you need each step's result before the next).
|
|
282
|
+
|
|
283
|
+
Mechanics:
|
|
284
|
+
- visible=True (default): real Terminal.app window, you watch child stdout
|
|
285
|
+
live. Window stays open after exit until Enter. Best for observation.
|
|
286
|
+
- visible=False: silent background `claude -p`, stdout/stderr redirected
|
|
287
|
+
to {TASK_LOG_DIR}/{task_id}.log (when capture_output=True).
|
|
288
|
+
Read via task_logs(task_id).
|
|
289
|
+
- permission_mode='auto' (default) — child runs in auto-mode and can call
|
|
290
|
+
thread-keeper tools without approval prompts.
|
|
291
|
+
- role= — apply a cognitive stance from ROLE_PROMPTS (problem_solver,
|
|
292
|
+
skeptic, summarizer, …); custom roles are supported.
|
|
293
|
+
- slim=True (DEFAULT): children are hands, not heads. Child loads ONLY
|
|
294
|
+
thread-keeper MCP (no context7/figma/stitch/sentry/etc), no embeddings
|
|
295
|
+
(no PyTorch/transformers), defers any semantic search to the parent
|
|
296
|
+
via search_via_parent. Typical light-child RSS is 400-500MB vs
|
|
297
|
+
1.3-1.7GB for a full child. Parent retains all heavy state. Use this
|
|
298
|
+
for any execute-this-plan task where the parent already knows what
|
|
299
|
+
needs doing.
|
|
300
|
+
- slim=False (rare): pass when the child genuinely needs OTHER MCP
|
|
301
|
+
servers from ~/.claude.json (e.g. context7 for library docs, figma
|
|
302
|
+
for design lookups). Default-deny posture — only opt out when you
|
|
303
|
+
have a concrete reason.
|
|
304
|
+
- Children share THIS DB — talk via broadcast/whisper/ask/inbox/wait;
|
|
305
|
+
child_cid is generated up-front and exposed via env so child self-knows.
|
|
306
|
+
|
|
307
|
+
Returns: task_id, pid (0 for visible), child_cid, parent_cid."""
|
|
308
|
+
prompt = prompt.strip()
|
|
309
|
+
if not prompt:
|
|
310
|
+
return "ERR empty_prompt"
|
|
311
|
+
cwd = cwd.strip() or os.getcwd()
|
|
312
|
+
if not Path(cwd).exists():
|
|
313
|
+
return f"ERR cwd_not_found={cwd}"
|
|
314
|
+
bin_ = _claude_bin()
|
|
315
|
+
if not bin_:
|
|
316
|
+
return "ERR claude_cli_not_found (set CLAUDE_CODE_EXECPATH or install claude)"
|
|
317
|
+
|
|
318
|
+
# Admission control: refuse if running children + this one would
|
|
319
|
+
# breach SPAWN_BUDGET_MB. Estimate based on slim vs full.
|
|
320
|
+
from ..spawn_budget import estimate_child_rss_kb, check_budget
|
|
321
|
+
_budget_conn = get_db()
|
|
322
|
+
_ensure_session(_budget_conn)
|
|
323
|
+
_new_kb = estimate_child_rss_kb(slim)
|
|
324
|
+
_ok, _reason = check_budget(_budget_conn, _new_kb)
|
|
325
|
+
if not _ok:
|
|
326
|
+
return f"ERR {_reason}"
|
|
327
|
+
|
|
328
|
+
parent_cid = _detect_self_cid()
|
|
329
|
+
# child_cid is generated below; we craft sys_extra after that so it can
|
|
330
|
+
# reference the exact ids. Build without it here, append after.
|
|
331
|
+
sys_extra_template = (
|
|
332
|
+
"You were spawned in the background by parent conversation "
|
|
333
|
+
"{parent}. Your own cid is {child} (forced via --session-id and "
|
|
334
|
+
"THREADKEEPER_FORCE_CID env). You share thread-keeper DB with "
|
|
335
|
+
"the parent.\n\n"
|
|
336
|
+
"Channels:\n"
|
|
337
|
+
" peers() — who's active\n"
|
|
338
|
+
" broadcast(content) — message to everyone\n"
|
|
339
|
+
" whisper(parent_cid, content) — directed message\n"
|
|
340
|
+
" inbox() — read pending signals\n"
|
|
341
|
+
" wait(timeout_s, kinds='question') — block until signal arrives\n"
|
|
342
|
+
" ask(cid, question) — synchronous q/a with peer\n"
|
|
343
|
+
" respond(qid, content) — answer a specific +question entry\n\n"
|
|
344
|
+
"If your task expects realtime back-and-forth with the parent, sit "
|
|
345
|
+
"in `wait(120, 'question')` loops between work units; otherwise just "
|
|
346
|
+
"broadcast/whisper a summary at the end.\n\n"
|
|
347
|
+
"When replying to the user: paraphrase in plain language. Do NOT "
|
|
348
|
+
"quote internal IDs (cids, signal #ids, thread T-codes, qids, "
|
|
349
|
+
"task tk_codes) — those are tool-call internals only."
|
|
350
|
+
)
|
|
351
|
+
# Generate the child's conversation_id up front. Pass it via --session-id
|
|
352
|
+
# so claude uses it as the jsonl stem, AND via env so the child's MCP
|
|
353
|
+
# server-process resolves itself to it via THREADKEEPER_FORCE_CID
|
|
354
|
+
# (no ppid-walk needed for spawned children).
|
|
355
|
+
import uuid as _uuid
|
|
356
|
+
child_cid = str(_uuid.uuid4())
|
|
357
|
+
sys_extra = sys_extra_template.format(
|
|
358
|
+
parent=parent_cid or "(unknown)",
|
|
359
|
+
child=child_cid,
|
|
360
|
+
)
|
|
361
|
+
role_clean = role.strip().lower()
|
|
362
|
+
if role_clean:
|
|
363
|
+
if role_clean in ROLE_PROMPTS:
|
|
364
|
+
sys_extra += f"\n\nROLE: {role_clean}\n{ROLE_PROMPTS[role_clean]}"
|
|
365
|
+
else:
|
|
366
|
+
sys_extra += (
|
|
367
|
+
f"\n\nROLE: {role_clean}\n"
|
|
368
|
+
f"(custom role — apply your own interpretation; predefined "
|
|
369
|
+
f"set: {', '.join(ROLE_PROMPTS.keys())})"
|
|
370
|
+
)
|
|
371
|
+
if append_system:
|
|
372
|
+
sys_extra += "\n\n" + append_system
|
|
373
|
+
cmd = [
|
|
374
|
+
bin_, "-p", prompt,
|
|
375
|
+
"--session-id", child_cid,
|
|
376
|
+
"--append-system-prompt", sys_extra,
|
|
377
|
+
]
|
|
378
|
+
if permission_mode:
|
|
379
|
+
cmd += ["--permission-mode", permission_mode]
|
|
380
|
+
# Default allowlist: thread-keeper tools so the child can actually report
|
|
381
|
+
# back via broadcast/whisper without auto-mode classifier blocking. Users
|
|
382
|
+
# extend via extra_allowed_tools (e.g. for Bash/Edit/etc).
|
|
383
|
+
default_allow = [
|
|
384
|
+
"mcp__thread-keeper__broadcast",
|
|
385
|
+
"mcp__thread-keeper__whisper",
|
|
386
|
+
"mcp__thread-keeper__inbox",
|
|
387
|
+
"mcp__thread-keeper__wait",
|
|
388
|
+
"mcp__thread-keeper__ask",
|
|
389
|
+
"mcp__thread-keeper__respond",
|
|
390
|
+
"mcp__thread-keeper__peers",
|
|
391
|
+
"mcp__thread-keeper__whoami",
|
|
392
|
+
"mcp__thread-keeper__note",
|
|
393
|
+
"mcp__thread-keeper__open_thread",
|
|
394
|
+
"mcp__thread-keeper__close_thread",
|
|
395
|
+
"mcp__thread-keeper__search",
|
|
396
|
+
"mcp__thread-keeper__dialog_search",
|
|
397
|
+
"mcp__thread-keeper__brief",
|
|
398
|
+
"mcp__thread-keeper__context",
|
|
399
|
+
"mcp__thread-keeper__verbatim_user",
|
|
400
|
+
"mcp__thread-keeper__register_probe",
|
|
401
|
+
"mcp__thread-keeper__run_probe",
|
|
402
|
+
"mcp__thread-keeper__record_attempt",
|
|
403
|
+
"mcp__thread-keeper__reliability_for",
|
|
404
|
+
"mcp__thread-keeper__weak_spots",
|
|
405
|
+
"mcp__thread-keeper__pickup_candidates",
|
|
406
|
+
"mcp__thread-keeper__claim_pickup",
|
|
407
|
+
"mcp__thread-keeper__release_pickup",
|
|
408
|
+
"mcp__thread-keeper__register_concept",
|
|
409
|
+
"mcp__thread-keeper__list_concepts",
|
|
410
|
+
"mcp__thread-keeper__expand_concept",
|
|
411
|
+
"mcp__thread-keeper__distill",
|
|
412
|
+
"mcp__thread-keeper__vote_distill",
|
|
413
|
+
"mcp__thread-keeper__pending_distillates",
|
|
414
|
+
"mcp__thread-keeper__export_distillates",
|
|
415
|
+
"mcp__thread-keeper__find_invariants",
|
|
416
|
+
"mcp__thread-keeper__core_set",
|
|
417
|
+
"mcp__thread-keeper__core_remove",
|
|
418
|
+
"mcp__thread-keeper__core_list",
|
|
419
|
+
"mcp__thread-keeper__core_get",
|
|
420
|
+
"mcp__thread-keeper__link",
|
|
421
|
+
"mcp__thread-keeper__unlink",
|
|
422
|
+
"mcp__thread-keeper__neighbors",
|
|
423
|
+
"mcp__thread-keeper__tag_signal",
|
|
424
|
+
"mcp__thread-keeper__task_thread",
|
|
425
|
+
"mcp__thread-keeper__extract_recent",
|
|
426
|
+
"mcp__thread-keeper__review_candidates",
|
|
427
|
+
"mcp__thread-keeper__accept_candidate",
|
|
428
|
+
"mcp__thread-keeper__reject_candidate",
|
|
429
|
+
"mcp__thread-keeper__consolidate",
|
|
430
|
+
"mcp__thread-keeper__mark_skill_materialized",
|
|
431
|
+
"mcp__thread-keeper__skill_record",
|
|
432
|
+
"mcp__thread-keeper__skill_list",
|
|
433
|
+
"mcp__thread-keeper__curator_run",
|
|
434
|
+
"mcp__thread-keeper__search_via_parent",
|
|
435
|
+
]
|
|
436
|
+
extra_list = [t.strip() for t in extra_allowed_tools.split(",") if t.strip()]
|
|
437
|
+
allow = default_allow + extra_list
|
|
438
|
+
cmd += ["--allowedTools"] + allow
|
|
439
|
+
if model:
|
|
440
|
+
cmd += ["--model", model]
|
|
441
|
+
if effort:
|
|
442
|
+
cmd += ["--effort", effort]
|
|
443
|
+
task_id = "tk_" + secrets.token_hex(3)
|
|
444
|
+
# slim=True: load ONLY thread-keeper MCP server. Skips context7, figma,
|
|
445
|
+
# stitch and every other MCP from ~/.claude.json — typically a 4-6× RAM
|
|
446
|
+
# reduction and a 10-30s faster cold start. Use for review/curation
|
|
447
|
+
# children that only need thread-keeper DB access (no Bash/Edit beyond
|
|
448
|
+
# what claude provides as built-ins, no external API integrations).
|
|
449
|
+
if slim:
|
|
450
|
+
slim_cfg = _build_slim_mcp_config(task_id)
|
|
451
|
+
if slim_cfg is not None:
|
|
452
|
+
cmd += ["--mcp-config", str(slim_cfg), "--strict-mcp-config"]
|
|
453
|
+
log_path: Optional[Path] = None
|
|
454
|
+
TASK_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
if capture_output and not visible:
|
|
456
|
+
log_path = TASK_LOG_DIR / f"{task_id}.log"
|
|
457
|
+
child_env = {**os.environ, "THREADKEEPER_FORCE_CID": child_cid}
|
|
458
|
+
if write_origin:
|
|
459
|
+
child_env["THREADKEEPER_WRITE_ORIGIN"] = write_origin
|
|
460
|
+
# slim spawn → child loads NO embeddings (delegates semantic search to
|
|
461
|
+
# the parent via search_via_parent). Override only if user didn't set
|
|
462
|
+
# the env explicitly already (allow opt-out by setting =0 explicitly).
|
|
463
|
+
if slim and "THREADKEEPER_NO_EMBEDDINGS" not in child_env:
|
|
464
|
+
child_env["THREADKEEPER_NO_EMBEDDINGS"] = "1"
|
|
465
|
+
proc_pid = 0
|
|
466
|
+
try:
|
|
467
|
+
if visible:
|
|
468
|
+
# Build a self-contained .command shell script that Terminal.app
|
|
469
|
+
# will execute in a fresh window. We export env, cd, exec claude,
|
|
470
|
+
# then `read` so the window stays open for inspection.
|
|
471
|
+
script_path = TASK_LOG_DIR / f"{task_id}.command"
|
|
472
|
+
cmd_line = " \\\n ".join(shlex.quote(a) for a in cmd)
|
|
473
|
+
env_pairs = [
|
|
474
|
+
("THREADKEEPER_FORCE_CID", child_cid),
|
|
475
|
+
("THREADKEEPER_TZ",
|
|
476
|
+
os.environ.get("THREADKEEPER_TZ", "UTC")),
|
|
477
|
+
]
|
|
478
|
+
if write_origin:
|
|
479
|
+
env_pairs.append(
|
|
480
|
+
("THREADKEEPER_WRITE_ORIGIN", write_origin)
|
|
481
|
+
)
|
|
482
|
+
if slim and "THREADKEEPER_NO_EMBEDDINGS" not in os.environ:
|
|
483
|
+
env_pairs.append(("THREADKEEPER_NO_EMBEDDINGS", "1"))
|
|
484
|
+
env_lines = "\n".join(
|
|
485
|
+
f"export {k}={shlex.quote(v)}" for k, v in env_pairs
|
|
486
|
+
)
|
|
487
|
+
# tag the terminal window with a unique title so the closer
|
|
488
|
+
# AppleScript finds exactly this tab (front-window heuristics
|
|
489
|
+
# break when the user switches focus during the run).
|
|
490
|
+
tag = f"thread-keeper-{task_id}"
|
|
491
|
+
close_apple = (
|
|
492
|
+
f'tell application "Terminal"\n'
|
|
493
|
+
f' repeat with w in windows\n'
|
|
494
|
+
f' repeat with t in tabs of w\n'
|
|
495
|
+
f' try\n'
|
|
496
|
+
f' if (name of t) contains "{tag}" then\n'
|
|
497
|
+
f' close w saving no\n'
|
|
498
|
+
f' return\n'
|
|
499
|
+
f' end if\n'
|
|
500
|
+
f' end try\n'
|
|
501
|
+
f' end repeat\n'
|
|
502
|
+
f' end repeat\n'
|
|
503
|
+
f'end tell'
|
|
504
|
+
)
|
|
505
|
+
script = f"""#!/bin/bash
|
|
506
|
+
set -u
|
|
507
|
+
{env_lines}
|
|
508
|
+
cd {shlex.quote(cwd)}
|
|
509
|
+
printf '\\033]0;{tag}\\007'
|
|
510
|
+
echo '── thread-keeper spawn ────────────────'
|
|
511
|
+
echo " task_id : {task_id}"
|
|
512
|
+
echo " cid : {child_cid}"
|
|
513
|
+
echo " parent : {(parent_cid or '-')}"
|
|
514
|
+
echo " perm : {permission_mode}"
|
|
515
|
+
echo '────────────────────────────────────────'
|
|
516
|
+
echo
|
|
517
|
+
{cmd_line}
|
|
518
|
+
rc=$?
|
|
519
|
+
echo
|
|
520
|
+
echo "── done (exit=$rc) — closing in 2s ──"
|
|
521
|
+
sleep 2
|
|
522
|
+
( osascript <<'OSA' >/dev/null 2>&1 &
|
|
523
|
+
{close_apple}
|
|
524
|
+
OSA
|
|
525
|
+
)
|
|
526
|
+
exit $rc
|
|
527
|
+
"""
|
|
528
|
+
script_path.write_text(script)
|
|
529
|
+
script_path.chmod(0o755)
|
|
530
|
+
try:
|
|
531
|
+
subprocess.Popen(
|
|
532
|
+
["open", "-a", "Terminal", str(script_path)],
|
|
533
|
+
env=child_env,
|
|
534
|
+
)
|
|
535
|
+
except (FileNotFoundError, OSError) as e:
|
|
536
|
+
return f"ERR open_terminal_failed={e}"
|
|
537
|
+
# pid for Terminal-launched claude isn't directly trackable from
|
|
538
|
+
# here; tasks() relies on spawned_cid + jsonl mtime instead.
|
|
539
|
+
proc_pid = 0
|
|
540
|
+
else:
|
|
541
|
+
if log_path is not None:
|
|
542
|
+
log_f = log_path.open("wb")
|
|
543
|
+
proc = subprocess.Popen(
|
|
544
|
+
cmd,
|
|
545
|
+
cwd=cwd,
|
|
546
|
+
stdin=subprocess.DEVNULL,
|
|
547
|
+
stdout=log_f,
|
|
548
|
+
stderr=subprocess.STDOUT,
|
|
549
|
+
start_new_session=True,
|
|
550
|
+
env=child_env,
|
|
551
|
+
)
|
|
552
|
+
log_f.close()
|
|
553
|
+
else:
|
|
554
|
+
proc = subprocess.Popen(
|
|
555
|
+
cmd,
|
|
556
|
+
cwd=cwd,
|
|
557
|
+
stdin=subprocess.DEVNULL,
|
|
558
|
+
stdout=subprocess.DEVNULL,
|
|
559
|
+
stderr=subprocess.DEVNULL,
|
|
560
|
+
start_new_session=True,
|
|
561
|
+
env=child_env,
|
|
562
|
+
)
|
|
563
|
+
proc_pid = proc.pid
|
|
564
|
+
except (FileNotFoundError, OSError) as e:
|
|
565
|
+
return f"ERR spawn_failed={e}"
|
|
566
|
+
now_t = int(time.time())
|
|
567
|
+
conn = get_db()
|
|
568
|
+
_ensure_session(conn)
|
|
569
|
+
conn.execute(
|
|
570
|
+
"INSERT INTO tasks (id, pid, parent_cid, spawned_cid, cwd, prompt, "
|
|
571
|
+
"started_at, rss_kb, rss_updated_at) "
|
|
572
|
+
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
573
|
+
(task_id, proc_pid, parent_cid, child_cid, cwd, prompt, now_t,
|
|
574
|
+
_new_kb, now_t),
|
|
575
|
+
)
|
|
576
|
+
_emit(conn, "spawn", target=task_id, summary=prompt[:140])
|
|
577
|
+
conn.commit()
|
|
578
|
+
mode = "visible" if visible else "headless"
|
|
579
|
+
log_disp = log_path or ("Terminal.app" if visible else "devnull")
|
|
580
|
+
return (
|
|
581
|
+
f"ok task={task_id} pid={proc_pid} child_cid={child_cid[:8]} "
|
|
582
|
+
f"parent_cid={(parent_cid or '-')[:8]} "
|
|
583
|
+
f"perm={permission_mode or '-'} mode={mode} log={log_disp}"
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@mcp.tool()
|
|
588
|
+
def tournament(prompt: str,
|
|
589
|
+
roles: str = "skeptic,generator,critic",
|
|
590
|
+
cwd: str = "",
|
|
591
|
+
timeout_s: int = 240,
|
|
592
|
+
visible: bool = False,
|
|
593
|
+
model: str = "",
|
|
594
|
+
effort: str = "") -> str:
|
|
595
|
+
"""Spawn N children with different roles on the same prompt, then collect
|
|
596
|
+
their answers via a tagged broadcast and return a comparison.
|
|
597
|
+
|
|
598
|
+
`roles`: comma-separated role names. Predefined: skeptic, generator,
|
|
599
|
+
critic, archivist, synthesizer, explorer, executor. Custom names allowed
|
|
600
|
+
(child gets generic instruction). Each role gets a distinct system
|
|
601
|
+
prompt addendum encoding its mindset.
|
|
602
|
+
|
|
603
|
+
Each child is told to broadcast its final output as exactly:
|
|
604
|
+
[<tournament_id>] [<role>] <answer>
|
|
605
|
+
Parent polls signals every 2s for matching prefixes until all answered
|
|
606
|
+
or timeout.
|
|
607
|
+
|
|
608
|
+
Returns: a per-role digest. Children write everything to thread-keeper
|
|
609
|
+
so you can also inspect via tasks()/dialog_search() afterward.
|
|
610
|
+
|
|
611
|
+
`visible=False` (default for tournaments — opening 5 Terminal windows is
|
|
612
|
+
obnoxious). Override per-need."""
|
|
613
|
+
import re
|
|
614
|
+
role_list = [r.strip().lower() for r in roles.split(",") if r.strip()]
|
|
615
|
+
if not role_list:
|
|
616
|
+
return "ERR no_roles"
|
|
617
|
+
if len(role_list) > 8:
|
|
618
|
+
return f"ERR too_many_roles={len(role_list)} (max 8)"
|
|
619
|
+
self_cid = _detect_self_cid()
|
|
620
|
+
if not self_cid:
|
|
621
|
+
return "ERR cannot_detect_self_cid"
|
|
622
|
+
tid = "trn_" + secrets.token_hex(3)
|
|
623
|
+
cwd = cwd.strip() or os.getcwd()
|
|
624
|
+
|
|
625
|
+
spawned: list[dict] = []
|
|
626
|
+
aug_template = (
|
|
627
|
+
"Tournament {tid}, role: {role}.\n\n"
|
|
628
|
+
"Task:\n{task}\n\n"
|
|
629
|
+
"When you're done, broadcast EXACTLY this single line (no markdown, "
|
|
630
|
+
"no quotes, replace <answer> with your final output):\n"
|
|
631
|
+
" [{tid}] [{role}] <answer>\n"
|
|
632
|
+
"Keep <answer> under 600 chars. That's the only required deliverable; "
|
|
633
|
+
"the tournament organizer harvests broadcasts matching that prefix."
|
|
634
|
+
)
|
|
635
|
+
for role in role_list:
|
|
636
|
+
aug = aug_template.format(tid=tid, role=role, task=prompt)
|
|
637
|
+
# call spawn() — it's a regular Python function under @mcp.tool
|
|
638
|
+
result = spawn(
|
|
639
|
+
prompt=aug,
|
|
640
|
+
cwd=cwd,
|
|
641
|
+
visible=visible,
|
|
642
|
+
model=model,
|
|
643
|
+
effort=effort,
|
|
644
|
+
permission_mode="auto",
|
|
645
|
+
role=role,
|
|
646
|
+
)
|
|
647
|
+
m = re.search(r"task=(\S+)\s+.*child_cid=(\S+)", result)
|
|
648
|
+
if m:
|
|
649
|
+
spawned.append({
|
|
650
|
+
"role": role, "task_id": m.group(1),
|
|
651
|
+
"cid_short": m.group(2), "spawn_result": result,
|
|
652
|
+
})
|
|
653
|
+
else:
|
|
654
|
+
spawned.append({"role": role, "error": result})
|
|
655
|
+
|
|
656
|
+
started_at = int(time.time())
|
|
657
|
+
deadline = started_at + max(15, min(int(timeout_s), 600))
|
|
658
|
+
conn = get_db()
|
|
659
|
+
collected: dict[str, dict] = {}
|
|
660
|
+
line_re = re.compile(
|
|
661
|
+
rf"^\[{re.escape(tid)}\]\s*\[([^\]]+)\]\s*(.*)$", re.DOTALL
|
|
662
|
+
)
|
|
663
|
+
while len(collected) < len(role_list) and time.time() < deadline:
|
|
664
|
+
rows = conn.execute(
|
|
665
|
+
"SELECT id, from_cid, content, created_at FROM signals "
|
|
666
|
+
"WHERE kind='broadcast' AND created_at >= ? "
|
|
667
|
+
"AND content LIKE ? ORDER BY created_at",
|
|
668
|
+
(started_at - 2, f"[{tid}]%"),
|
|
669
|
+
).fetchall()
|
|
670
|
+
for r in rows:
|
|
671
|
+
m = line_re.match(r["content"])
|
|
672
|
+
if not m:
|
|
673
|
+
continue
|
|
674
|
+
role_found = m.group(1).strip().lower()
|
|
675
|
+
ans = m.group(2).strip()
|
|
676
|
+
if role_found not in collected:
|
|
677
|
+
collected[role_found] = {
|
|
678
|
+
"answer": ans,
|
|
679
|
+
"from": r["from_cid"][:8],
|
|
680
|
+
"at": r["created_at"],
|
|
681
|
+
}
|
|
682
|
+
if len(collected) >= len(role_list):
|
|
683
|
+
break
|
|
684
|
+
time.sleep(2)
|
|
685
|
+
|
|
686
|
+
elapsed = int(time.time() - started_at)
|
|
687
|
+
out = [
|
|
688
|
+
f"tournament={tid} got={len(collected)}/{len(role_list)} "
|
|
689
|
+
f"elapsed={elapsed}s"
|
|
690
|
+
]
|
|
691
|
+
for s in spawned:
|
|
692
|
+
if "error" in s:
|
|
693
|
+
out.append(f"\n## {s['role']} — SPAWN_FAILED\n{s['error']}")
|
|
694
|
+
continue
|
|
695
|
+
role = s["role"]
|
|
696
|
+
if role in collected:
|
|
697
|
+
d = collected[role]
|
|
698
|
+
out.append(
|
|
699
|
+
f"\n## {role} (from {d['from']}, "
|
|
700
|
+
f"+{fmt_age(int(time.time()) - d['at'])}_ago)"
|
|
701
|
+
)
|
|
702
|
+
out.append(d["answer"][:1200])
|
|
703
|
+
else:
|
|
704
|
+
out.append(
|
|
705
|
+
f"\n## {role} — TIMEOUT (no broadcast within {elapsed}s; "
|
|
706
|
+
f"task {s['task_id']} may still be running, check tasks())"
|
|
707
|
+
)
|
|
708
|
+
return "\n".join(out)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
@mcp.tool()
|
|
712
|
+
def tasks(include_ended: bool = True, k: int = 15) -> str:
|
|
713
|
+
"""List spawned tasks: id, pid, status, elapsed, spawned_cid (if linked),
|
|
714
|
+
prompt prefix. Refreshes liveness and resolves spawned_cid lazily."""
|
|
715
|
+
conn = get_db()
|
|
716
|
+
_ensure_session(conn)
|
|
717
|
+
_refresh_tasks(conn)
|
|
718
|
+
where = "" if include_ended else "WHERE ended_at IS NULL"
|
|
719
|
+
rows = conn.execute(
|
|
720
|
+
f"SELECT * FROM tasks {where} ORDER BY started_at DESC LIMIT ?", (k,)
|
|
721
|
+
).fetchall()
|
|
722
|
+
if not rows:
|
|
723
|
+
return "no_tasks"
|
|
724
|
+
now_t = int(time.time())
|
|
725
|
+
lines = []
|
|
726
|
+
for t in rows:
|
|
727
|
+
is_visible = not t["pid"] or t["pid"] <= 0
|
|
728
|
+
if t["ended_at"]:
|
|
729
|
+
status = f"done@{fmt_age(now_t - t['ended_at'])}_ago"
|
|
730
|
+
elif is_visible:
|
|
731
|
+
vstatus, _end = _visible_task_status(
|
|
732
|
+
t["cwd"], t["spawned_cid"], t["started_at"]
|
|
733
|
+
)
|
|
734
|
+
status = vstatus
|
|
735
|
+
elif alive(t["pid"]):
|
|
736
|
+
status = "running"
|
|
737
|
+
else:
|
|
738
|
+
status = "dead?"
|
|
739
|
+
elapsed = fmt_age(
|
|
740
|
+
(t["ended_at"] or now_t) - t["started_at"]
|
|
741
|
+
)
|
|
742
|
+
snip = t["prompt"][:60].replace("\n", " ")
|
|
743
|
+
if len(t["prompt"]) > 60:
|
|
744
|
+
snip += "…"
|
|
745
|
+
cid = (t["spawned_cid"] or "-")[:8]
|
|
746
|
+
pid_disp = "vis" if is_visible else str(t["pid"])
|
|
747
|
+
lines.append(
|
|
748
|
+
f"{t['id']} pid={pid_disp} {status} elapsed={elapsed} "
|
|
749
|
+
f"cid={cid} {q(snip)}"
|
|
750
|
+
)
|
|
751
|
+
return "\n".join(lines)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@mcp.tool()
|
|
755
|
+
def task_logs(task_id: str, tail_lines: int = 80) -> str:
|
|
756
|
+
"""Read tail of a spawned task's captured stdout/stderr log.
|
|
757
|
+
|
|
758
|
+
Only works for tasks spawned with `capture_output=True` (default).
|
|
759
|
+
Returns the last `tail_lines` lines or 'no_log' if the task ran with
|
|
760
|
+
capture_output=False or the log file is missing."""
|
|
761
|
+
log_path = TASK_LOG_DIR / f"{task_id}.log"
|
|
762
|
+
if not log_path.exists():
|
|
763
|
+
return f"no_log path={log_path}"
|
|
764
|
+
try:
|
|
765
|
+
with log_path.open("rb") as f:
|
|
766
|
+
data = f.read()
|
|
767
|
+
except OSError as e:
|
|
768
|
+
return f"ERR read_failed={e}"
|
|
769
|
+
text = data.decode("utf-8", errors="replace")
|
|
770
|
+
lines = text.splitlines()
|
|
771
|
+
if tail_lines and len(lines) > tail_lines:
|
|
772
|
+
lines = lines[-tail_lines:]
|
|
773
|
+
return "\n".join(lines) if lines else "(empty)"
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
@mcp.tool()
|
|
777
|
+
def spawn_budget_status() -> str:
|
|
778
|
+
"""Report current spawn-budget usage: cap, used, free, plus per-running-task
|
|
779
|
+
RSS. Used to decide whether another spawn() will be admitted.
|
|
780
|
+
|
|
781
|
+
Values come from the budget daemon (refreshes every SPAWN_BUDGET_POLL_S
|
|
782
|
+
seconds via `ps`). Just-spawned tasks show their initial estimate until
|
|
783
|
+
the daemon catches up. Tasks with pid=0 (visible Terminal-launched
|
|
784
|
+
spawns) aren't tracked from here — their RSS column stays as estimate."""
|
|
785
|
+
from ..config import SPAWN_BUDGET_MB, SPAWN_BUDGET_POLL_S
|
|
786
|
+
conn = get_db()
|
|
787
|
+
_ensure_session(conn)
|
|
788
|
+
_refresh_tasks(conn)
|
|
789
|
+
rows = conn.execute(
|
|
790
|
+
"SELECT id, pid, spawned_cid, prompt, rss_kb, rss_updated_at, "
|
|
791
|
+
"started_at FROM tasks WHERE ended_at IS NULL "
|
|
792
|
+
"ORDER BY started_at DESC LIMIT 20"
|
|
793
|
+
).fetchall()
|
|
794
|
+
now_t = int(time.time())
|
|
795
|
+
used_kb = sum(
|
|
796
|
+
(r["rss_kb"] or 0) for r in rows
|
|
797
|
+
)
|
|
798
|
+
if SPAWN_BUDGET_MB <= 0:
|
|
799
|
+
header = (
|
|
800
|
+
f"budget=disabled used={used_kb // 1024}MB "
|
|
801
|
+
f"running={len(rows)}"
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
free_kb = max(0, SPAWN_BUDGET_MB * 1024 - used_kb)
|
|
805
|
+
header = (
|
|
806
|
+
f"budget={SPAWN_BUDGET_MB}MB used={used_kb // 1024}MB "
|
|
807
|
+
f"free={free_kb // 1024}MB running={len(rows)} "
|
|
808
|
+
f"poll={SPAWN_BUDGET_POLL_S}s"
|
|
809
|
+
)
|
|
810
|
+
if not rows:
|
|
811
|
+
return header
|
|
812
|
+
lines = [header]
|
|
813
|
+
for r in rows:
|
|
814
|
+
rss_mb = (r["rss_kb"] or 0) // 1024
|
|
815
|
+
age_at = r["rss_updated_at"] or r["started_at"]
|
|
816
|
+
age = fmt_age(now_t - age_at)
|
|
817
|
+
snip = r["prompt"][:50].replace("\n", " ")
|
|
818
|
+
if len(r["prompt"]) > 50:
|
|
819
|
+
snip += "…"
|
|
820
|
+
cid = (r["spawned_cid"] or "-")[:8]
|
|
821
|
+
pid_disp = "vis" if not r["pid"] or r["pid"] <= 0 else str(r["pid"])
|
|
822
|
+
lines.append(
|
|
823
|
+
f" {r['id']} pid={pid_disp} cid={cid} rss={rss_mb}MB "
|
|
824
|
+
f"age={age} {q(snip)}"
|
|
825
|
+
)
|
|
826
|
+
return "\n".join(lines)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
@mcp.tool()
|
|
830
|
+
def spawn_budget_set(limit_mb: int) -> str:
|
|
831
|
+
"""Override the spawn-budget cap for this process (in MB). Set 0 to
|
|
832
|
+
disable enforcement. Does NOT persist across restarts — set
|
|
833
|
+
THREADKEEPER_SPAWN_BUDGET_MB env for persistence.
|
|
834
|
+
|
|
835
|
+
Useful when a heavy task needs a higher temporary ceiling, or to drop
|
|
836
|
+
the cap mid-session if you notice the laptop struggling."""
|
|
837
|
+
if limit_mb < 0:
|
|
838
|
+
return "ERR limit_mb_must_be_non_negative"
|
|
839
|
+
from .. import config
|
|
840
|
+
config.SPAWN_BUDGET_MB = int(limit_mb)
|
|
841
|
+
if limit_mb == 0:
|
|
842
|
+
return "ok: budget enforcement DISABLED (existing children unaffected)"
|
|
843
|
+
return f"ok: SPAWN_BUDGET_MB now {limit_mb}MB (was via env or previous override)"
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@mcp.tool()
|
|
847
|
+
def task_kill(task_id: str, force: bool = False) -> str:
|
|
848
|
+
"""Stop a spawned task. SIGTERM by default; force=True sends SIGKILL."""
|
|
849
|
+
conn = get_db()
|
|
850
|
+
_ensure_session(conn)
|
|
851
|
+
row = conn.execute(
|
|
852
|
+
"SELECT pid, ended_at FROM tasks WHERE id=?", (task_id,)
|
|
853
|
+
).fetchone()
|
|
854
|
+
if not row:
|
|
855
|
+
return f"ERR task_not_found={task_id}"
|
|
856
|
+
if row["ended_at"]:
|
|
857
|
+
return f"already_ended task={task_id}"
|
|
858
|
+
pid = row["pid"]
|
|
859
|
+
sig_to_send = _sig.SIGKILL if force else _sig.SIGTERM
|
|
860
|
+
try:
|
|
861
|
+
os.kill(pid, sig_to_send)
|
|
862
|
+
except ProcessLookupError:
|
|
863
|
+
conn.execute(
|
|
864
|
+
"UPDATE tasks SET ended_at=? WHERE id=?",
|
|
865
|
+
(int(time.time()), task_id),
|
|
866
|
+
)
|
|
867
|
+
conn.commit()
|
|
868
|
+
return f"already_dead task={task_id}"
|
|
869
|
+
except PermissionError:
|
|
870
|
+
return f"ERR permission_denied pid={pid}"
|
|
871
|
+
return f"signal={sig_to_send.name} sent task={task_id} pid={pid}"
|