threadkeeper 0.5.3__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/PKG-INFO +18 -2
  2. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/README.md +17 -1
  3. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/pyproject.toml +1 -1
  4. threadkeeper-0.6.0/tests/test_memory_guard.py +219 -0
  5. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_nudges.py +66 -0
  6. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_shadow_review.py +55 -0
  7. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_spawn_slim.py +17 -2
  8. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/_setup.py +37 -5
  9. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/brief.py +28 -0
  10. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/candidate_reviewer.py +5 -4
  11. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/config.py +49 -0
  12. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/curator.py +5 -3
  13. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/db.py +16 -0
  14. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/embeddings.py +52 -18
  15. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/extract_daemon.py +5 -3
  16. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/identity.py +10 -0
  17. threadkeeper-0.6.0/threadkeeper/memory_guard.py +487 -0
  18. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/nudges.py +62 -1
  19. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/server.py +1 -0
  20. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/shadow_review.py +53 -12
  21. threadkeeper-0.6.0/threadkeeper/tools/memory_guard.py +127 -0
  22. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/spawn.py +36 -10
  23. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper.egg-info/PKG-INFO +18 -2
  24. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper.egg-info/SOURCES.txt +3 -0
  25. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/LICENSE +0 -0
  26. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/setup.cfg +0 -0
  27. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_adapters.py +0 -0
  28. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_brief_sections.py +0 -0
  29. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_candidate_reviewer.py +0 -0
  30. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_core_memory.py +0 -0
  31. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_curator.py +0 -0
  32. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_delegated_search.py +0 -0
  33. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_dialectic.py +0 -0
  34. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_dialectic_tier.py +0 -0
  35. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_error_paths.py +0 -0
  36. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_extract_daemon.py +0 -0
  37. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_i18n_multilang.py +0 -0
  38. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_identity.py +0 -0
  39. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_lessons.py +0 -0
  40. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_missed_spawns.py +0 -0
  41. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_process_health.py +0 -0
  42. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_skill_hint.py +0 -0
  43. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_skill_tier.py +0 -0
  44. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_skill_use_parser.py +0 -0
  45. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_skill_watcher.py +0 -0
  46. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_skills.py +0 -0
  47. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_spawn_budget.py +0 -0
  48. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_spawn_config.py +0 -0
  49. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_spawn_hint.py +0 -0
  50. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_threads.py +0 -0
  51. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_tools_smoke.py +0 -0
  52. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_validate_threads.py +0 -0
  53. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/tests/test_vec_search.py +0 -0
  54. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/__init__.py +0 -0
  55. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/_mcp.py +0 -0
  56. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/__init__.py +0 -0
  57. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/_hook_helpers.py +0 -0
  58. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/base.py +0 -0
  59. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/claude_code.py +0 -0
  60. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/claude_desktop.py +0 -0
  61. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/codex.py +0 -0
  62. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/copilot.py +0 -0
  63. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/gemini.py +0 -0
  64. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/adapters/vscode.py +0 -0
  65. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/helpers.py +0 -0
  66. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/i18n.py +0 -0
  67. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/ingest.py +0 -0
  68. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/lessons.py +0 -0
  69. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/process_health.py +0 -0
  70. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/review_prompts.py +0 -0
  71. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/search_proxy.py +0 -0
  72. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/skill_watcher.py +0 -0
  73. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/spawn_budget.py +0 -0
  74. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/spawn_config.py +0 -0
  75. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/__init__.py +0 -0
  76. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/candidate_reviewer.py +0 -0
  77. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/concepts.py +0 -0
  78. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/consolidate.py +0 -0
  79. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/core_memory.py +0 -0
  80. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/correlation.py +0 -0
  81. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/curator.py +0 -0
  82. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/dialectic.py +0 -0
  83. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/dialog.py +0 -0
  84. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/distill.py +0 -0
  85. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/extract.py +0 -0
  86. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/graph.py +0 -0
  87. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/invariants.py +0 -0
  88. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/lessons.py +0 -0
  89. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/missed_spawns.py +0 -0
  90. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/peers.py +0 -0
  91. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/pickup.py +0 -0
  92. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/probes.py +0 -0
  93. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/process_health.py +0 -0
  94. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/session.py +0 -0
  95. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/shadow_review.py +0 -0
  96. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/skills.py +0 -0
  97. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/style.py +0 -0
  98. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/threads.py +0 -0
  99. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper/tools/validate.py +0 -0
  100. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper.egg-info/dependency_links.txt +0 -0
  101. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper.egg-info/entry_points.txt +0 -0
  102. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper.egg-info/requires.txt +0 -0
  103. {threadkeeper-0.5.3 → threadkeeper-0.6.0}/threadkeeper.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadkeeper
3
- Version: 0.5.3
3
+ Version: 0.6.0
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server.
5
5
  Author: thread-keeper contributors
6
6
  License: MIT
@@ -183,6 +183,8 @@ Claude session via a `claude -p` subprocess. By default `slim=True`: the
183
183
  child loads only the thread-keeper MCP, no embeddings, no third-party
184
184
  servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
185
185
  parent: N≥2 modular independent units of ≥5 min each = spawn signal.
186
+ Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
187
+ autonomous learning daemons cannot recursively start inside review forks.
186
188
 
187
189
  A daemon measures combined child RSS every 10 s; admission control
188
190
  refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
@@ -275,7 +277,11 @@ the last cursor **across all CLIs at once**. The window filters
275
277
  internal review-child sessions (no self-pollution) and strips adapter
276
278
  `[tool_result]` / `[tool_call]` noise (the "clean context" rule). If
277
279
  ≥500 chars of meaningful signal remain, spawns a slim observer child
278
- that decides on class-level learning. Idempotent through
280
+ that decides on class-level learning. It is single-flight across the shared
281
+ DB: if any shadow observer task is already running, the daemon does not spawn
282
+ another one and does not advance the cursor. Shadow observer children are
283
+ marked as spawned/background processes, so they cannot start their own shadow
284
+ daemon even if a CLI drops the no-embeddings env. Idempotent through
279
285
  `events.kind='shadow_review_pass'`.
280
286
 
281
287
  #### 3. Extract daemon
@@ -418,8 +424,18 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
418
424
  | `THREADKEEPER_CURATOR_MIN_LESSONS` | 3 | min lessons before curator engages |
419
425
  | `THREADKEEPER_CURATOR_DESTRUCTIVE` | "" (advisory) | when "1": curator child applies its own PATCH/PRUNE/CONSOLIDATE directly instead of writing advisory REPORT only |
420
426
  | `THREADKEEPER_SPAWN_BUDGET_MB` | 3072 | combined child RSS cap (MB); 0 disables |
427
+ | `THREADKEEPER_MEMORY_GUARD_POLL_S` | 30 | server RSS guard tick (s); 0 disables |
428
+ | `THREADKEEPER_MEMORY_GUARD_WARN_MB` | 1536 | notify/log when a server crosses this RSS |
429
+ | `THREADKEEPER_MEMORY_GUARD_KILL_MB` | 3072 | SIGTERM server above this RSS; 0 disables killing |
430
+ | `THREADKEEPER_MEMORY_GUARD_AGG_WARN_MB` | 2048 | notify/request trim when all server RSS crosses this |
431
+ | `THREADKEEPER_MEMORY_GUARD_AGG_KILL_MB` | 3072 | under aggregate pressure, retire stale idle servers |
432
+ | `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
433
+ | `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
434
+ | `THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S` | 900 | heartbeat age before a non-self server is retireable |
435
+ | `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
421
436
  | `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
422
437
  | `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
438
+ | `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
423
439
  | `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
424
440
 
425
441
  Persist them via `~/.claude/settings.json`'s `env` block (Claude Code) or
@@ -149,6 +149,8 @@ Claude session via a `claude -p` subprocess. By default `slim=True`: the
149
149
  child loads only the thread-keeper MCP, no embeddings, no third-party
150
150
  servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
151
151
  parent: N≥2 modular independent units of ≥5 min each = spawn signal.
152
+ Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
153
+ autonomous learning daemons cannot recursively start inside review forks.
152
154
 
153
155
  A daemon measures combined child RSS every 10 s; admission control
154
156
  refuses a new spawn that would exceed `THREADKEEPER_SPAWN_BUDGET_MB`
@@ -241,7 +243,11 @@ the last cursor **across all CLIs at once**. The window filters
241
243
  internal review-child sessions (no self-pollution) and strips adapter
242
244
  `[tool_result]` / `[tool_call]` noise (the "clean context" rule). If
243
245
  ≥500 chars of meaningful signal remain, spawns a slim observer child
244
- that decides on class-level learning. Idempotent through
246
+ that decides on class-level learning. It is single-flight across the shared
247
+ DB: if any shadow observer task is already running, the daemon does not spawn
248
+ another one and does not advance the cursor. Shadow observer children are
249
+ marked as spawned/background processes, so they cannot start their own shadow
250
+ daemon even if a CLI drops the no-embeddings env. Idempotent through
245
251
  `events.kind='shadow_review_pass'`.
246
252
 
247
253
  #### 3. Extract daemon
@@ -384,8 +390,18 @@ The most-used env knobs (full list in `threadkeeper/config.py`):
384
390
  | `THREADKEEPER_CURATOR_MIN_LESSONS` | 3 | min lessons before curator engages |
385
391
  | `THREADKEEPER_CURATOR_DESTRUCTIVE` | "" (advisory) | when "1": curator child applies its own PATCH/PRUNE/CONSOLIDATE directly instead of writing advisory REPORT only |
386
392
  | `THREADKEEPER_SPAWN_BUDGET_MB` | 3072 | combined child RSS cap (MB); 0 disables |
393
+ | `THREADKEEPER_MEMORY_GUARD_POLL_S` | 30 | server RSS guard tick (s); 0 disables |
394
+ | `THREADKEEPER_MEMORY_GUARD_WARN_MB` | 1536 | notify/log when a server crosses this RSS |
395
+ | `THREADKEEPER_MEMORY_GUARD_KILL_MB` | 3072 | SIGTERM server above this RSS; 0 disables killing |
396
+ | `THREADKEEPER_MEMORY_GUARD_AGG_WARN_MB` | 2048 | notify/request trim when all server RSS crosses this |
397
+ | `THREADKEEPER_MEMORY_GUARD_AGG_KILL_MB` | 3072 | under aggregate pressure, retire stale idle servers |
398
+ | `THREADKEEPER_MEMORY_GUARD_RECLAIM_MB` | 1024 | local RSS floor before warn-triggered self trim |
399
+ | `THREADKEEPER_MEMORY_GUARD_TARGET_SERVERS` | 1 | aggregate-pressure target after retiring stale idle servers |
400
+ | `THREADKEEPER_MEMORY_GUARD_RETIRE_IDLE_S` | 900 | heartbeat age before a non-self server is retireable |
401
+ | `THREADKEEPER_MEMORY_GUARD_NOTIFY` | "1" | send macOS desktop notification when possible |
387
402
  | `THREADKEEPER_INGEST_INTERVAL_S` | 3 | transcript ingest tick (s) |
388
403
  | `THREADKEEPER_NO_EMBEDDINGS` | "" | force-disable sentence-transformers |
404
+ | `THREADKEEPER_SPAWNED_CHILD` | "" | spawn-internal marker; disables autonomous daemons in children |
389
405
  | `THREADKEEPER_SKILL_NUDGE_INTERVAL` | 10 | events between `skill_hint` nudges |
390
406
 
391
407
  Persist them via `~/.claude/settings.json`'s `env` block (Claude Code) or
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.5.3"
7
+ version = "0.6.0"
8
8
  description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Gemini, Copilot, VS Code. Cross-session memory, self-improving skill loops, inter-agent signaling — one local MCP server."
9
9
  requires-python = ">=3.11"
10
10
  authors = [{ name = "thread-keeper contributors" }]
@@ -0,0 +1,219 @@
1
+ """Memory guard for thread-keeper server RSS thresholds."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal as _sig
6
+
7
+
8
+ _FAKE_CID = "55556666-7777-8888-9999-000011112222"
9
+
10
+
11
+ def _tool(pkg, name):
12
+ return pkg["mcp"]._tool_manager._tools[name].fn
13
+
14
+
15
+ def _proc(pid, rss_mb, *, ppid=None):
16
+ return {
17
+ "pid": pid,
18
+ "ppid": os.getpid() if ppid is None else ppid,
19
+ "rss_kb": rss_mb * 1024,
20
+ "rss_mb": rss_mb,
21
+ "etime": "1:00",
22
+ "command": "python -m threadkeeper.server",
23
+ "parent_alive": True,
24
+ "heartbeat_age_s": 5,
25
+ "is_self": pid == os.getpid(),
26
+ "is_orphaned": False,
27
+ "orphan_reason": "parent_alive",
28
+ }
29
+
30
+
31
+ def test_scan_over_limit_splits_warn_and_kill(mp_with_cid, monkeypatch):
32
+ mp_with_cid(_FAKE_CID)
33
+ from threadkeeper import memory_guard, process_health
34
+
35
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
36
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
37
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
38
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
39
+ monkeypatch.setattr(process_health, "scan", lambda: [
40
+ _proc(1001, 800),
41
+ _proc(1002, 1200),
42
+ _proc(1003, 2500),
43
+ ])
44
+
45
+ out = memory_guard.scan_over_limit()
46
+ assert [p["pid"] for p in out["warn"]] == [1002]
47
+ assert [p["pid"] for p in out["kill"]] == [1003]
48
+
49
+
50
+ def test_check_dry_run_does_not_kill(mp_with_cid, monkeypatch):
51
+ mp_with_cid(_FAKE_CID)
52
+ from threadkeeper import memory_guard, process_health
53
+
54
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
55
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
56
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
57
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
58
+ monkeypatch.setattr(process_health, "scan", lambda: [_proc(1003, 2500)])
59
+ killed: list[tuple[int, int]] = []
60
+ monkeypatch.setattr(
61
+ "os.kill",
62
+ lambda pid, sig: killed.append((pid, sig)) if sig != 0 else None,
63
+ )
64
+
65
+ out = memory_guard.check_once(dry_run=True, notify=False)
66
+ assert [p["pid"] for p in out["kill"]] == [1003]
67
+ assert out["killed"] == []
68
+ assert killed == []
69
+
70
+
71
+ def test_check_apply_sends_sigterm(mp_with_cid, monkeypatch):
72
+ mp_with_cid(_FAKE_CID)
73
+ from threadkeeper import memory_guard, process_health
74
+
75
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
76
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
77
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
78
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
79
+ monkeypatch.setattr(process_health, "scan", lambda: [_proc(1004, 2500)])
80
+ calls: list[tuple[int, int]] = []
81
+ monkeypatch.setattr("os.kill", lambda pid, sig: calls.append((pid, sig)))
82
+ monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
83
+ "before_mb": 2500, "after_mb": 2400, "freed_mb": 100,
84
+ "pid": os.getpid(), "actions": [],
85
+ })
86
+
87
+ out = memory_guard.check_once(dry_run=False, notify=False)
88
+ assert out["killed"] == [1004]
89
+ assert calls == [(1004, _sig.SIGTERM)]
90
+
91
+
92
+ def test_memory_guard_status_tool_reports_thresholds(mp_with_cid, monkeypatch):
93
+ pkg = mp_with_cid(_FAKE_CID)
94
+ from threadkeeper import memory_guard, process_health
95
+
96
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_POLL_S", 30)
97
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
98
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
99
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
100
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
101
+ monkeypatch.setattr(process_health, "scan", lambda: [
102
+ _proc(1001, 800),
103
+ _proc(1002, 1200),
104
+ _proc(1003, 2500),
105
+ ])
106
+
107
+ txt = _tool(pkg, "memory_guard_status")()
108
+ assert "state=active" in txt
109
+ assert "warn_mb=1000" in txt
110
+ assert "kill_mb=2000" in txt
111
+ assert "ok pid=1001" in txt
112
+ assert "WARN pid=1002" in txt
113
+ assert "KILL pid=1003" in txt
114
+
115
+
116
+ def test_memory_guard_check_tool_defaults_to_dry_run(mp_with_cid, monkeypatch):
117
+ pkg = mp_with_cid(_FAKE_CID)
118
+ from threadkeeper import memory_guard, process_health
119
+
120
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 1000)
121
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 2000)
122
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 0)
123
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
124
+ monkeypatch.setattr(process_health, "scan", lambda: [_proc(1005, 2500)])
125
+ killed: list[tuple[int, int]] = []
126
+ monkeypatch.setattr(
127
+ "os.kill",
128
+ lambda pid, sig: killed.append((pid, sig)) if sig != 0 else None,
129
+ )
130
+
131
+ txt = _tool(pkg, "memory_guard_check")()
132
+ assert txt.startswith("dry_run")
133
+ assert "would SIGTERM pid=1005" in txt
134
+ assert killed == []
135
+
136
+
137
+ def test_scan_reports_aggregate_pressure(mp_with_cid, monkeypatch):
138
+ mp_with_cid(_FAKE_CID)
139
+ from threadkeeper import memory_guard, process_health
140
+
141
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
142
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
143
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
144
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
145
+ monkeypatch.setattr(process_health, "scan", lambda: [
146
+ _proc(1001, 800),
147
+ _proc(1002, 900),
148
+ _proc(1003, 1200),
149
+ ])
150
+
151
+ out = memory_guard.scan_over_limit()
152
+ assert out["aggregate"]["rss_mb"] == 2900
153
+ assert out["aggregate"]["warn"] is True
154
+ assert out["aggregate"]["kill"] is False
155
+ assert out["warn"] == []
156
+ assert out["kill"] == []
157
+
158
+
159
+ def test_check_apply_requests_peer_trim_on_aggregate_warn(mp_with_cid, monkeypatch):
160
+ pkg = mp_with_cid(_FAKE_CID)
161
+ from threadkeeper import memory_guard, process_health
162
+
163
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
164
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
165
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
166
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 0)
167
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
168
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
169
+ monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
170
+ "before_mb": 900, "after_mb": 800, "freed_mb": 100,
171
+ "pid": os.getpid(), "actions": ["fake"],
172
+ })
173
+ monkeypatch.setattr(process_health, "scan", lambda: [
174
+ _proc(os.getpid(), 900),
175
+ _proc(1002, 1200, ppid=os.getpid()),
176
+ ])
177
+
178
+ out = memory_guard.check_once(dry_run=False, notify=False)
179
+ assert sorted(out["reclaim_requests"]["requested"]) == sorted([os.getpid(), 1002])
180
+
181
+ conn = pkg["db"].get_db()
182
+ rows = conn.execute(
183
+ "SELECT target_pid FROM resource_controls WHERE action='trim'"
184
+ ).fetchall()
185
+ assert sorted(r["target_pid"] for r in rows) == sorted([os.getpid(), 1002])
186
+
187
+
188
+ def test_check_apply_retires_idle_candidate_on_aggregate_pressure(mp_with_cid, monkeypatch):
189
+ mp_with_cid(_FAKE_CID)
190
+ from threadkeeper import memory_guard, process_health
191
+
192
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_WARN_MB", 5000)
193
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_KILL_MB", 6000)
194
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_WARN_MB", 2000)
195
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_AGG_KILL_MB", 3000)
196
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_TARGET_SERVERS", 1)
197
+ monkeypatch.setattr(memory_guard, "MEMORY_GUARD_RETIRE_IDLE_S", 900)
198
+ monkeypatch.setattr(memory_guard, "reclaim_memory", lambda reason="": {
199
+ "before_mb": 900, "after_mb": 800, "freed_mb": 100,
200
+ "pid": os.getpid(), "actions": ["fake"],
201
+ })
202
+
203
+ def scan():
204
+ return [
205
+ _proc(os.getpid(), 900),
206
+ _proc(1001, 1200, ppid=os.getpid()) | {"heartbeat_age_s": None},
207
+ _proc(1002, 800, ppid=os.getpid()) | {"heartbeat_age_s": 5},
208
+ ]
209
+
210
+ monkeypatch.setattr(process_health, "scan", scan)
211
+ calls: list[tuple[int, int]] = []
212
+ monkeypatch.setattr(
213
+ "os.kill",
214
+ lambda pid, sig: calls.append((pid, sig)) if sig != 0 else None,
215
+ )
216
+
217
+ out = memory_guard.check_once(dry_run=False, notify=False)
218
+ assert out["retired"] == [1001]
219
+ assert calls == [(1001, _sig.SIGTERM)]
@@ -438,3 +438,69 @@ def test_close_thread_with_auto_review_enabled_spawns(tmp_path, monkeypatch):
438
438
  assert len(calls) == 1
439
439
  assert calls[0]["thread_id"] == tid
440
440
  assert calls[0]["mode"] == "auto"
441
+
442
+
443
+ # ──────────────────────────────────────────────────────────────────────────
444
+ # compute_thread_nudge (open-thread nudge for hook-less clients)
445
+ # ──────────────────────────────────────────────────────────────────────────
446
+
447
+ def _insert_event(pkg, session_id: str, kind: str) -> None:
448
+ conn = pkg["db"].get_db()
449
+ conn.execute(
450
+ "INSERT INTO events (session_id, kind, target, summary, created_at) "
451
+ "VALUES (?,?,?,?,?)",
452
+ (session_id, kind, None, "", int(time.time())),
453
+ )
454
+ conn.commit()
455
+
456
+
457
+ def test_thread_nudge_fires_on_empty_session(tmp_path, monkeypatch):
458
+ """No activity threshold — fires as soon as a session exists with no
459
+ open_thread (mirrors the hook firing on the first prompt)."""
460
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
461
+ conn = pkg["db"].get_db()
462
+ out = pkg["nudges"].compute_thread_nudge(conn, "sess-empty")
463
+ assert out is not None
464
+ assert "thread_hint" in out
465
+ assert "open_thread(question)" in out
466
+
467
+
468
+ def test_thread_nudge_silent_after_open_thread(tmp_path, monkeypatch):
469
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
470
+ _insert_event(pkg, "sess-B", "open_thread")
471
+ conn = pkg["db"].get_db()
472
+ assert pkg["nudges"].compute_thread_nudge(conn, "sess-B") is None
473
+
474
+
475
+ def test_thread_nudge_silent_after_shown(tmp_path, monkeypatch):
476
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
477
+ _insert_event(pkg, "sess-C", "thread_hint_shown")
478
+ conn = pkg["db"].get_db()
479
+ assert pkg["nudges"].compute_thread_nudge(conn, "sess-C") is None
480
+
481
+
482
+ def test_thread_nudge_silent_without_session_id(tmp_path, monkeypatch):
483
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
484
+ conn = pkg["db"].get_db()
485
+ assert pkg["nudges"].compute_thread_nudge(conn, "") is None
486
+
487
+
488
+ def test_brief_shows_thread_hint_once_then_suppresses(tmp_path, monkeypatch):
489
+ """brief() surfaces the nudge on the first call (no env set = hook-less
490
+ client), logs thread_hint_shown, and stays quiet thereafter."""
491
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
492
+ monkeypatch.delenv("THREADKEEPER_BRIEF_NO_THREAD_NUDGE", raising=False)
493
+ brief = _tool(pkg, "brief")
494
+ out1 = brief()
495
+ assert "thread_hint" in out1
496
+ out2 = brief()
497
+ assert "thread_hint" not in out2
498
+
499
+
500
+ def test_brief_suppresses_thread_hint_when_env_set(tmp_path, monkeypatch):
501
+ """SessionStart-hook path (env set) never surfaces the in-brief nudge —
502
+ the UserPromptSubmit hook owns it there."""
503
+ pkg = _bootstrap_with_env(tmp_path, monkeypatch)
504
+ monkeypatch.setenv("THREADKEEPER_BRIEF_NO_THREAD_NUDGE", "1")
505
+ out = _tool(pkg, "brief")()
506
+ assert "thread_hint" not in out
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import time
13
13
  import sys
14
+ import os
14
15
  from pathlib import Path
15
16
 
16
17
  import pytest
@@ -258,6 +259,42 @@ def test_run_shadow_pass_spawns_when_threshold_met(tmp_path, monkeypatch):
258
259
  assert long_msg.strip()[:40] in kw["prompt"]
259
260
 
260
261
 
262
+ def test_run_shadow_pass_single_flight_when_child_running(tmp_path, monkeypatch):
263
+ pkg = _bootstrap(tmp_path, monkeypatch, min_chars="100")
264
+ conn = pkg["db"].get_db()
265
+ now = int(time.time())
266
+ long_msg = "Pattern: in this type of task always X. " * 10
267
+ _seed_dialog(conn, "user", long_msg, now - 5)
268
+ conn.execute(
269
+ "INSERT INTO tasks (id, pid, parent_cid, spawned_cid, cwd, prompt, "
270
+ "started_at, rss_kb, rss_updated_at) "
271
+ "VALUES (?,?,?,?,?,?,?,?,?)",
272
+ (
273
+ "tk_shadow_running",
274
+ os.getpid(),
275
+ "parent",
276
+ "child",
277
+ str(tmp_path),
278
+ pkg["shadow_review"].SHADOW_REVIEW_PROMPT,
279
+ now - 1,
280
+ 123,
281
+ now,
282
+ ),
283
+ )
284
+ conn.commit()
285
+
286
+ import threadkeeper.tools.spawn as spawn_mod
287
+
288
+ def should_not_spawn(**kwargs):
289
+ raise AssertionError("shadow pass should be single-flight")
290
+
291
+ monkeypatch.setattr(spawn_mod, "spawn", should_not_spawn)
292
+ out = pkg["shadow_review"].run_shadow_pass(force=True)
293
+ assert out == "shadow_child_running n=1"
294
+ # Cursor does not advance; retry the same window when the child exits.
295
+ assert pkg["shadow_review"]._last_shadow_ts(conn) == 0
296
+
297
+
261
298
  def test_run_shadow_pass_idempotent_after_cursor_advance(tmp_path, monkeypatch):
262
299
  """Second pass over the same data must produce no_window once cursor
263
300
  catches up."""
@@ -314,6 +351,24 @@ def test_daemon_does_not_start_in_slim_child(tmp_path, monkeypatch):
314
351
  )
315
352
 
316
353
 
354
+ def test_daemon_does_not_start_in_marked_spawned_child(tmp_path, monkeypatch):
355
+ """The cascade guard must not depend only on NO_EMBEDDINGS.
356
+
357
+ Some CLIs launch MCP servers from a config env block, so the child
358
+ process may not reliably inherit THREADKEEPER_NO_EMBEDDINGS. The explicit
359
+ THREADKEEPER_SPAWNED_CHILD marker still has to stop shadow_review.
360
+ """
361
+ monkeypatch.setenv("THREADKEEPER_SPAWNED_CHILD", "1")
362
+ pkg = _bootstrap(tmp_path, monkeypatch, interval="60")
363
+ import threadkeeper.config as cfg
364
+ monkeypatch.setattr(cfg, "SEMANTIC_AVAILABLE", True)
365
+ pkg["shadow_review"]._started = False
366
+ pkg["shadow_review"].start_shadow_daemon()
367
+ assert pkg["shadow_review"]._started is False, (
368
+ "marked spawned child should refuse to start shadow daemon"
369
+ )
370
+
371
+
317
372
  def test_mcp_shadow_review_status_reports_passes(tmp_path, monkeypatch):
318
373
  pkg = _bootstrap(tmp_path, monkeypatch)
319
374
  pkg["shadow_review"]._record_shadow_pass(
@@ -44,7 +44,12 @@ def test_build_slim_mcp_config_from_claude_json(tmp_path, monkeypatch):
44
44
  del sys.modules[name]
45
45
  from threadkeeper.tools.spawn import _build_slim_mcp_config
46
46
 
47
- slim_path = _build_slim_mcp_config("tk_test01")
47
+ slim_path = _build_slim_mcp_config("tk_test01", {
48
+ "THREADKEEPER_FORCE_CID": _FAKE_CID,
49
+ "THREADKEEPER_SPAWNED_CHILD": "1",
50
+ "THREADKEEPER_NO_EMBEDDINGS": "1",
51
+ "THREADKEEPER_WRITE_ORIGIN": "shadow_review",
52
+ })
48
53
  assert slim_path is not None
49
54
  assert slim_path.exists()
50
55
  data = json.loads(slim_path.read_text())
@@ -53,6 +58,11 @@ def test_build_slim_mcp_config_from_claude_json(tmp_path, monkeypatch):
53
58
  mp = data["mcpServers"]["thread-keeper"]
54
59
  assert mp["command"] == "/path/to/python"
55
60
  assert mp["args"] == ["-m", "threadkeeper.server"]
61
+ assert mp["env"]["PYTHONPATH"] == "/path/to/repo"
62
+ assert mp["env"]["THREADKEEPER_FORCE_CID"] == _FAKE_CID
63
+ assert mp["env"]["THREADKEEPER_SPAWNED_CHILD"] == "1"
64
+ assert mp["env"]["THREADKEEPER_NO_EMBEDDINGS"] == "1"
65
+ assert mp["env"]["THREADKEEPER_WRITE_ORIGIN"] == "shadow_review"
56
66
 
57
67
 
58
68
  def test_build_slim_mcp_config_synthesizes_when_no_claude_json(tmp_path, monkeypatch):
@@ -70,7 +80,10 @@ def test_build_slim_mcp_config_synthesizes_when_no_claude_json(tmp_path, monkeyp
70
80
  del sys.modules[name]
71
81
  from threadkeeper.tools.spawn import _build_slim_mcp_config
72
82
 
73
- slim_path = _build_slim_mcp_config("tk_synth")
83
+ slim_path = _build_slim_mcp_config("tk_synth", {
84
+ "THREADKEEPER_SPAWNED_CHILD": "1",
85
+ "THREADKEEPER_NO_EMBEDDINGS": "1",
86
+ })
74
87
  assert slim_path is not None
75
88
  data = json.loads(slim_path.read_text())
76
89
  assert "thread-keeper" in data["mcpServers"]
@@ -79,6 +92,8 @@ def test_build_slim_mcp_config_synthesizes_when_no_claude_json(tmp_path, monkeyp
79
92
  assert mp["command"] == sys.executable
80
93
  assert "threadkeeper.server" in mp["args"]
81
94
  assert "PYTHONPATH" in mp["env"]
95
+ assert mp["env"]["THREADKEEPER_SPAWNED_CHILD"] == "1"
96
+ assert mp["env"]["THREADKEEPER_NO_EMBEDDINGS"] == "1"
82
97
 
83
98
 
84
99
  def test_spawn_slim_falls_back_to_full_config_when_unable(tmp_path, monkeypatch):
@@ -2,8 +2,8 @@
2
2
 
3
3
  Idempotently wires thread-keeper into a Claude Code installation:
4
4
  1. Registers `thread-keeper` MCP server in ~/.claude.json
5
- 2. Installs hooks (SessionStart, PostToolUse, UserPromptSubmit) in
6
- ~/.claude/settings.json
5
+ 2. Installs hooks (SessionStart, PostToolUse, UserPromptSubmit, Stop,
6
+ PreToolUse) in ~/.claude/settings.json
7
7
  3. Copies hook scripts to ~/.threadkeeper/hooks/
8
8
  4. Updates the managed block in ~/.claude/CLAUDE.md between sentinel
9
9
  markers — content outside the markers is preserved.
@@ -69,8 +69,11 @@ At session start:
69
69
  If the user's opening message is substantive, pass it as `query`
70
70
  to `brief()` to inline relevant past notes.
71
71
 
72
- During the conversation:
73
- - New substantive topic `open_thread()`.
72
+ During the conversation (a UserPromptSubmit hook nudges you once per
73
+ session if you haven't opened a thread, and a Stop hook reminds you to
74
+ close at the end — but the discipline is yours; act before the nudge):
75
+ - The FIRST substantive topic of a session (debugging, a feature, a
76
+ multi-step task) → `open_thread()` BEFORE diving into tool calls.
74
77
  - Topic resolved with an outcome → `close_thread(thread_id, outcome)`.
75
78
  - After every turn that produced a decision or insight →
76
79
  `note(thread_id, ..., kind in ['move','failed','insight','open_q'])`.
@@ -149,7 +152,14 @@ def install_hooks(dry_run: bool) -> list[str]:
149
152
  # and is referenced by every supporting CLI.
150
153
  if not dry_run:
151
154
  TK_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
152
- for fname in ("tk-brief.sh", "tk-status.sh", "inbox-check.sh"):
155
+ for fname in (
156
+ "tk-brief.sh",
157
+ "tk-status.sh",
158
+ "inbox-check.sh",
159
+ "tk-task-gate.sh",
160
+ "tk-thread-nudge.sh",
161
+ "tk-session-end.sh",
162
+ ):
153
163
  src = HOOKS_SRC / fname
154
164
  dst = TK_HOOKS_DIR / fname
155
165
  if not src.exists():
@@ -182,6 +192,28 @@ def install_hooks(dry_run: bool) -> list[str]:
182
192
  "matcher": "",
183
193
  "command": str(TK_HOOKS_DIR / "inbox-check.sh"),
184
194
  },
195
+ # Open-thread safety net: once per session, nudge open_thread() if
196
+ # none was opened yet (additionalContext, non-blocking).
197
+ {
198
+ "event": "UserPromptSubmit",
199
+ "matcher": "",
200
+ "command": str(TK_HOOKS_DIR / "tk-thread-nudge.sh"),
201
+ },
202
+ # close_thread / session_end safety net at end of turn (throttled to
203
+ # once per session; advisory systemMessage, never blocks stopping).
204
+ {
205
+ "event": "Stop",
206
+ "matcher": "",
207
+ "command": str(TK_HOOKS_DIR / "tk-session-end.sh"),
208
+ },
209
+ # spawn-vs-Task gate: block the built-in Task tool for work that
210
+ # should go through mcp__thread-keeper__spawn() (Claude Code only;
211
+ # other CLIs ignore an unknown PreToolUse event).
212
+ {
213
+ "event": "PreToolUse",
214
+ "matcher": "^Task$",
215
+ "command": str(TK_HOOKS_DIR / "tk-task-gate.sh"),
216
+ },
185
217
  ]
186
218
 
187
219
  # 3) Ask each installed adapter to wire them up in its native
@@ -10,6 +10,7 @@ pickup_top, evolve_pending, and the trailing user-facing reminder.
10
10
  shared dialog log tailed by open_dialog_window().
11
11
  """
12
12
 
13
+ import os
13
14
  import re
14
15
  import sqlite3
15
16
  import time
@@ -339,6 +340,33 @@ def render_brief(conn: sqlite3.Connection, query: str = "", k: int = 6) -> str:
339
340
  for t in closed_t:
340
341
  out.append(f" {t['id']} out={q((t['outcome'] or '-')[:120])}")
341
342
 
343
+ # ── thread_hint (open-thread nudge for hook-less clients) ─────────────
344
+ # Claude Code / Gemini / Copilot get the open-thread reminder from the
345
+ # UserPromptSubmit hook (tk-thread-nudge.sh); their SessionStart hook
346
+ # sets THREADKEEPER_BRIEF_NO_THREAD_NUDGE so this path stays quiet and
347
+ # doesn't double-fire. Clients with NO hook mechanism (Claude Desktop,
348
+ # Codex, VS Code) call brief() directly — they have no env set, so the
349
+ # nudge surfaces here instead. See nudges.compute_thread_nudge.
350
+ if not os.environ.get("THREADKEEPER_BRIEF_NO_THREAD_NUDGE"):
351
+ try:
352
+ from .nudges import compute_thread_nudge
353
+ th_nudge = compute_thread_nudge(conn, identity._session_id or "")
354
+ except (sqlite3.OperationalError, ImportError):
355
+ th_nudge = None
356
+ if th_nudge:
357
+ out.append("")
358
+ out.append(th_nudge)
359
+ try:
360
+ conn.execute(
361
+ "INSERT INTO events (session_id, kind, target, summary, "
362
+ "created_at) VALUES (?,?,?,?,?)",
363
+ (identity._session_id or "", "thread_hint_shown",
364
+ self_cid or "", "", now),
365
+ )
366
+ conn.commit()
367
+ except sqlite3.OperationalError:
368
+ pass
369
+
342
370
  # ── memory_nudge ──────────────────────────────────────────────────────
343
371
  # Counter-driven (active push, not just passive surface): when N mutating
344
372
  # events have passed in this session without a memory save, escalate from
@@ -323,15 +323,16 @@ def _serve_loop() -> None:
323
323
 
324
324
 
325
325
  def start_candidate_reviewer_daemon() -> None:
326
- """Idempotent daemon starter. Slim-children cascade prevention via
327
- SEMANTIC_AVAILABLE, same pattern as shadow_review / curator /
328
- extract."""
326
+ """Idempotent daemon starter. Uses the same spawned/background child
327
+ cascade prevention as shadow_review / curator / extract."""
329
328
  global _started
330
329
  if _started:
331
330
  return
332
331
  if CANDIDATE_REVIEW_INTERVAL_S <= 0:
333
332
  return
334
- from .config import SEMANTIC_AVAILABLE
333
+ from .config import BACKGROUND_DAEMONS_ALLOWED, SEMANTIC_AVAILABLE
334
+ if not BACKGROUND_DAEMONS_ALLOWED:
335
+ return
335
336
  if not SEMANTIC_AVAILABLE:
336
337
  return
337
338
  t = threading.Thread(