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.
Files changed (61) hide show
  1. threadkeeper/__init__.py +8 -0
  2. threadkeeper/_mcp.py +6 -0
  3. threadkeeper/_setup.py +299 -0
  4. threadkeeper/adapters/__init__.py +40 -0
  5. threadkeeper/adapters/_hook_helpers.py +72 -0
  6. threadkeeper/adapters/base.py +152 -0
  7. threadkeeper/adapters/claude_code.py +178 -0
  8. threadkeeper/adapters/claude_desktop.py +128 -0
  9. threadkeeper/adapters/codex.py +259 -0
  10. threadkeeper/adapters/copilot.py +195 -0
  11. threadkeeper/adapters/gemini.py +169 -0
  12. threadkeeper/adapters/vscode.py +144 -0
  13. threadkeeper/brief.py +735 -0
  14. threadkeeper/config.py +216 -0
  15. threadkeeper/curator.py +390 -0
  16. threadkeeper/db.py +474 -0
  17. threadkeeper/embeddings.py +232 -0
  18. threadkeeper/extract_daemon.py +125 -0
  19. threadkeeper/helpers.py +101 -0
  20. threadkeeper/i18n.py +342 -0
  21. threadkeeper/identity.py +237 -0
  22. threadkeeper/ingest.py +507 -0
  23. threadkeeper/lessons.py +170 -0
  24. threadkeeper/nudges.py +257 -0
  25. threadkeeper/process_health.py +202 -0
  26. threadkeeper/review_prompts.py +207 -0
  27. threadkeeper/search_proxy.py +160 -0
  28. threadkeeper/server.py +55 -0
  29. threadkeeper/shadow_review.py +358 -0
  30. threadkeeper/skill_watcher.py +96 -0
  31. threadkeeper/spawn_budget.py +246 -0
  32. threadkeeper/tools/__init__.py +2 -0
  33. threadkeeper/tools/concepts.py +111 -0
  34. threadkeeper/tools/consolidate.py +222 -0
  35. threadkeeper/tools/core_memory.py +109 -0
  36. threadkeeper/tools/correlation.py +116 -0
  37. threadkeeper/tools/curator.py +121 -0
  38. threadkeeper/tools/dialectic.py +359 -0
  39. threadkeeper/tools/dialog.py +131 -0
  40. threadkeeper/tools/distill.py +184 -0
  41. threadkeeper/tools/extract.py +411 -0
  42. threadkeeper/tools/graph.py +183 -0
  43. threadkeeper/tools/invariants.py +177 -0
  44. threadkeeper/tools/lessons.py +110 -0
  45. threadkeeper/tools/missed_spawns.py +142 -0
  46. threadkeeper/tools/peers.py +579 -0
  47. threadkeeper/tools/pickup.py +148 -0
  48. threadkeeper/tools/probes.py +251 -0
  49. threadkeeper/tools/process_health.py +90 -0
  50. threadkeeper/tools/session.py +34 -0
  51. threadkeeper/tools/shadow_review.py +106 -0
  52. threadkeeper/tools/skills.py +856 -0
  53. threadkeeper/tools/spawn.py +871 -0
  54. threadkeeper/tools/style.py +44 -0
  55. threadkeeper/tools/threads.py +299 -0
  56. threadkeeper-0.4.0.dist-info/METADATA +351 -0
  57. threadkeeper-0.4.0.dist-info/RECORD +61 -0
  58. threadkeeper-0.4.0.dist-info/WHEEL +5 -0
  59. threadkeeper-0.4.0.dist-info/entry_points.txt +2 -0
  60. threadkeeper-0.4.0.dist-info/licenses/LICENSE +21 -0
  61. 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}"