threadkeeper 0.18.1__tar.gz → 0.18.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. {threadkeeper-0.18.1/threadkeeper.egg-info → threadkeeper-0.18.3}/PKG-INFO +6 -1
  2. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/README.md +5 -0
  3. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/pyproject.toml +1 -1
  4. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_adapters.py +103 -0
  5. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_github_safety.py +35 -0
  6. threadkeeper-0.18.3/tests/test_hook_child_guard.py +38 -0
  7. threadkeeper-0.18.3/tests/test_spawn_child_env.py +101 -0
  8. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_slim.py +32 -12
  9. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/base.py +27 -0
  10. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/codex.py +132 -13
  11. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/github_safety.py +24 -7
  12. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/spawn_config.py +18 -0
  13. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/spawn.py +22 -11
  14. {threadkeeper-0.18.1 → threadkeeper-0.18.3/threadkeeper.egg-info}/PKG-INFO +6 -1
  15. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper.egg-info/SOURCES.txt +2 -0
  16. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/LICENSE +0 -0
  17. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/setup.cfg +0 -0
  18. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_agent_status.py +0 -0
  19. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_antigravity_ingest.py +0 -0
  20. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_auto_update.py +0 -0
  21. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_backup.py +0 -0
  22. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_brief_footprint.py +0 -0
  23. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_brief_sections.py +0 -0
  24. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_candidate_reviewer.py +0 -0
  25. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_ci_parallel.py +0 -0
  26. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_concepts.py +0 -0
  27. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_config_settings.py +0 -0
  28. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_config_watcher.py +0 -0
  29. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_core_memory.py +0 -0
  30. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_curator.py +0 -0
  31. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_daemon_host_config.py +0 -0
  32. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_daemon_host_integration.py +0 -0
  33. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_daemon_liveness.py +0 -0
  34. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_daemon_state.py +0 -0
  35. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dashboard.py +0 -0
  36. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_db_compact.py +0 -0
  37. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_db_contention.py +0 -0
  38. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_db_leak_guard.py +0 -0
  39. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_db_migration_fts.py +0 -0
  40. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_db_runtime.py +0 -0
  41. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_db_schema.py +0 -0
  42. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_delegated_search.py +0 -0
  43. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic.py +0 -0
  44. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic_feed_tools.py +0 -0
  45. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic_miner.py +0 -0
  46. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic_observation_resolve.py +0 -0
  47. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic_recompute.py +0 -0
  48. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic_tier.py +0 -0
  49. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialectic_validator.py +0 -0
  50. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_dialog_fts_external.py +0 -0
  51. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_documentation_inventory.py +0 -0
  52. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_egress_policy.py +0 -0
  53. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_embed_routing.py +0 -0
  54. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_embedding_generation.py +0 -0
  55. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_embedding_model_revision.py +0 -0
  56. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_embedding_storage_dedup.py +0 -0
  57. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_error_paths.py +0 -0
  58. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_eval_harness.py +0 -0
  59. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_evolve_applier.py +0 -0
  60. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_evolve_apply_2.py +0 -0
  61. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_evolve_apply_3.py +0 -0
  62. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_evolve_daemon.py +0 -0
  63. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_extract_daemon.py +0 -0
  64. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_extract_dedup.py +0 -0
  65. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_forget.py +0 -0
  66. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_github_budget.py +0 -0
  67. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_helpers.py +0 -0
  68. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_host_embed.py +0 -0
  69. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_host_env.py +0 -0
  70. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_host_main.py +0 -0
  71. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_host_wedged.py +0 -0
  72. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_hybrid_retrieval.py +0 -0
  73. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_i18n_multilang.py +0 -0
  74. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_identity.py +0 -0
  75. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_ingest_cursor.py +0 -0
  76. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_ingest_denylist.py +0 -0
  77. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_ingest_secret_redaction.py +0 -0
  78. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_ingest_status.py +0 -0
  79. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_ingest_transactions.py +0 -0
  80. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_lesson_violations.py +0 -0
  81. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_lessons.py +0 -0
  82. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_mcp_resources_prompts.py +0 -0
  83. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_mcp_sdk_compat.py +0 -0
  84. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_memory_eval.py +0 -0
  85. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_memory_guard.py +0 -0
  86. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_memory_guard_host.py +0 -0
  87. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_menubar_app.py +0 -0
  88. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_missed_spawns.py +0 -0
  89. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_model_catalog.py +0 -0
  90. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_notify_daemon.py +0 -0
  91. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_nudges.py +0 -0
  92. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_onnx_embeddings.py +0 -0
  93. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_panel.py +0 -0
  94. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_permissions.py +0 -0
  95. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_pickup.py +0 -0
  96. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_probe_daemon.py +0 -0
  97. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_process_health.py +0 -0
  98. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_release_workflows.py +0 -0
  99. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_retention.py +0 -0
  100. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_review_prompts.py +0 -0
  101. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_search_fts_punctuation.py +0 -0
  102. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_session_read_purity.py +0 -0
  103. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_setup.py +0 -0
  104. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_shadow_review.py +0 -0
  105. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_single_flight_prompt_prefixes.py +0 -0
  106. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skill_hint.py +0 -0
  107. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skill_passive_tier.py +0 -0
  108. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skill_tier.py +0 -0
  109. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skill_updater.py +0 -0
  110. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skill_use_parser.py +0 -0
  111. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skill_watcher.py +0 -0
  112. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_skills.py +0 -0
  113. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_background_cwd.py +0 -0
  114. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_budget.py +0 -0
  115. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_codex_stdin.py +0 -0
  116. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_config.py +0 -0
  117. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_hint.py +0 -0
  118. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_permission_gate.py +0 -0
  119. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_reap.py +0 -0
  120. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_result.py +0 -0
  121. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_watchdog.py +0 -0
  122. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_worktree_isolation.py +0 -0
  123. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_spawn_wrap.py +0 -0
  124. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_sync_capture.py +0 -0
  125. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_sync_identity.py +0 -0
  126. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_sync_migrate.py +0 -0
  127. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_sync_protocol.py +0 -0
  128. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_sync_reset_node.py +0 -0
  129. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_sync_transport.py +0 -0
  130. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_task_retention.py +0 -0
  131. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_task_spool.py +0 -0
  132. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_thin_session.py +0 -0
  133. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_thread_janitor.py +0 -0
  134. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_threads.py +0 -0
  135. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_tool_annotations.py +0 -0
  136. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_tools_smoke.py +0 -0
  137. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_validate_threads.py +0 -0
  138. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_vec_search.py +0 -0
  139. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/tests/test_verify_ingest.py +0 -0
  140. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/__init__.py +0 -0
  141. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/_mcp.py +0 -0
  142. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/_setup.py +0 -0
  143. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/_spawn_wrap.py +0 -0
  144. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/__init__.py +0 -0
  145. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/_hook_helpers.py +0 -0
  146. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/antigravity.py +0 -0
  147. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/claude_code.py +0 -0
  148. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/claude_desktop.py +0 -0
  149. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/copilot.py +0 -0
  150. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/adapters/vscode.py +0 -0
  151. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/agent_metadata.py +0 -0
  152. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/agent_status.py +0 -0
  153. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/assets/macos-agent-status/Info.plist +0 -0
  154. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/assets/macos-agent-status/README.md +0 -0
  155. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/assets/macos-agent-status/ThreadKeeperAgentStatus.swift +0 -0
  156. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/assets/macos-agent-status/build.sh +0 -0
  157. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/assets/macos-agent-status/install.sh +0 -0
  158. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/auto_update.py +0 -0
  159. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/backup.py +0 -0
  160. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/brief.py +0 -0
  161. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/candidate_reviewer.py +0 -0
  162. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/config.py +0 -0
  163. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/config_io.py +0 -0
  164. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/config_watcher.py +0 -0
  165. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/curator.py +0 -0
  166. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/curator_snapshots.py +0 -0
  167. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/daemon_liveness.py +0 -0
  168. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/daemon_state.py +0 -0
  169. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/daemon_supervisor.py +0 -0
  170. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/db.py +0 -0
  171. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/dialectic_miner.py +0 -0
  172. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/dialectic_validator.py +0 -0
  173. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/egress.py +0 -0
  174. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/elicitation.py +0 -0
  175. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/embeddings.py +0 -0
  176. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/eval/__init__.py +0 -0
  177. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/eval/__main__.py +0 -0
  178. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/eval/harness.py +0 -0
  179. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/evolve_applier.py +0 -0
  180. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/evolve_daemon.py +0 -0
  181. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/extract_daemon.py +0 -0
  182. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/forget.py +0 -0
  183. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/github_budget.py +0 -0
  184. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/harvest.py +0 -0
  185. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/helpers.py +0 -0
  186. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/host.py +0 -0
  187. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/host_embed.py +0 -0
  188. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/i18n.py +0 -0
  189. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/identity.py +0 -0
  190. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/ingest.py +0 -0
  191. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/lesson_violations.py +0 -0
  192. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/lessons.py +0 -0
  193. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/link_health.py +0 -0
  194. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/memory_guard.py +0 -0
  195. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/menubar_app.py +0 -0
  196. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/migrate_embeddings.py +0 -0
  197. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/model_catalog.py +0 -0
  198. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/notify.py +0 -0
  199. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/nudges.py +0 -0
  200. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/permissions.py +0 -0
  201. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/probe_daemon.py +0 -0
  202. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/process_health.py +0 -0
  203. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/retention.py +0 -0
  204. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/retrieval.py +0 -0
  205. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/review_prompts.py +0 -0
  206. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/search_proxy.py +0 -0
  207. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/server.py +0 -0
  208. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/shadow_review.py +0 -0
  209. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/skill_audit.py +0 -0
  210. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/skill_updater.py +0 -0
  211. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/skill_watcher.py +0 -0
  212. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/spawn_budget.py +0 -0
  213. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/spawn_result.py +0 -0
  214. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/__init__.py +0 -0
  215. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/capture.py +0 -0
  216. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/daemon.py +0 -0
  217. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/identity.py +0 -0
  218. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/migrate.py +0 -0
  219. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/protocol.py +0 -0
  220. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/reset_node.py +0 -0
  221. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/sync/server.py +0 -0
  222. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/task_spool.py +0 -0
  223. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/thread_janitor.py +0 -0
  224. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tool_schemas.py +0 -0
  225. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/__init__.py +0 -0
  226. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/agent_status.py +0 -0
  227. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/candidate_reviewer.py +0 -0
  228. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/concepts.py +0 -0
  229. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/config_watch.py +0 -0
  230. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/consolidate.py +0 -0
  231. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/core_memory.py +0 -0
  232. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/correlation.py +0 -0
  233. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/curator.py +0 -0
  234. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/dashboard.py +0 -0
  235. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/db_maintenance.py +0 -0
  236. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/dialectic.py +0 -0
  237. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/dialectic_feed.py +0 -0
  238. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/dialog.py +0 -0
  239. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/distill.py +0 -0
  240. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/evolve_applier.py +0 -0
  241. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/evolve_research.py +0 -0
  242. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/extract.py +0 -0
  243. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/forget.py +0 -0
  244. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/graph.py +0 -0
  245. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/invariants.py +0 -0
  246. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/lessons.py +0 -0
  247. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/memory_guard.py +0 -0
  248. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/missed_spawns.py +0 -0
  249. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/panel.py +0 -0
  250. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/peers.py +0 -0
  251. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/pickup.py +0 -0
  252. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/probes.py +0 -0
  253. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/process_health.py +0 -0
  254. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/prompts.py +0 -0
  255. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/resources.py +0 -0
  256. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/session.py +0 -0
  257. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/shadow_review.py +0 -0
  258. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/skills.py +0 -0
  259. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/style.py +0 -0
  260. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/sync.py +0 -0
  261. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/threads.py +0 -0
  262. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/tools/validate.py +0 -0
  263. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/trash.py +0 -0
  264. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/verify_ingest.py +0 -0
  265. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper/wikilinks.py +0 -0
  266. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper.egg-info/dependency_links.txt +0 -0
  267. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper.egg-info/entry_points.txt +0 -0
  268. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/threadkeeper.egg-info/requires.txt +0 -0
  269. {threadkeeper-0.18.1 → threadkeeper-0.18.3}/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.18.1
3
+ Version: 0.18.3
4
4
  Summary: Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Copilot, and 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
@@ -313,6 +313,11 @@ servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
313
313
  parent: N≥2 modular independent units of ≥5 min each = spawn signal.
314
314
  Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
315
315
  autonomous learning daemons cannot recursively start inside review forks.
316
+ Codex children get the same isolation: `codex exec --ignore-user-config`
317
+ with only the user's provider settings and thread-keeper entry carried over,
318
+ and `enabled_tools` limited to the child's granted thread-keeper tools. Other
319
+ MCP servers, plugins and hooks from `~/.codex/config.toml` never start inside
320
+ a background child.
316
321
 
317
322
  A daemon in the foreground parent measures combined child RSS every 10 s;
318
323
  spawned children do not start their own `ps` polling loop, failed `ps` RSS
@@ -269,6 +269,11 @@ servers. ~500 MB RSS versus ~1.3 GB for a full child. Heuristic for the
269
269
  parent: N≥2 modular independent units of ≥5 min each = spawn signal.
270
270
  Spawn also marks children with `THREADKEEPER_SPAWNED_CHILD=1`, so
271
271
  autonomous learning daemons cannot recursively start inside review forks.
272
+ Codex children get the same isolation: `codex exec --ignore-user-config`
273
+ with only the user's provider settings and thread-keeper entry carried over,
274
+ and `enabled_tools` limited to the child's granted thread-keeper tools. Other
275
+ MCP servers, plugins and hooks from `~/.codex/config.toml` never start inside
276
+ a background child.
272
277
 
273
278
  A daemon in the foreground parent measures combined child RSS every 10 s;
274
279
  spawned children do not start their own `ps` polling loop, failed `ps` RSS
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadkeeper"
7
- version = "0.18.1"
7
+ version = "0.18.3"
8
8
  description = "Multi-agent shared brain across Claude Code/Desktop, Codex, Antigravity CLI, Copilot, and 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" }]
@@ -476,9 +476,112 @@ def test_codex_spawn_argv_forwards_child_identity_to_its_mcp_server(
476
476
  ):
477
477
  assert key in names
478
478
  assert "THREADKEEPER_ROLE" not in names # the host's role must not leak
479
+ assert "THREADKEEPER_REAL_GH" in names # gh wrapper must reach real gh
479
480
  assert all("=" not in name for name in names) # names only, no values
480
481
 
481
482
 
483
+ def _codex_overrides(argv):
484
+ return [argv[i + 1] for i, arg in enumerate(argv[:-1]) if arg == "-c"]
485
+
486
+
487
+ def _write_codex_config(path):
488
+ path.write_text(
489
+ 'model = "gpt-6-sol"\n'
490
+ 'model_reasoning_effort = "xhigh"\n'
491
+ 'personality = "pragmatic"\n'
492
+ 'notify = ["/Applications/Tool.app/notify"]\n'
493
+ '[model_providers.corp]\n'
494
+ 'base_url = "https://llm.example/v1"\n'
495
+ '[mcp_servers.trello]\n'
496
+ 'command = "trello-mcp"\n'
497
+ '[mcp_servers.thread-keeper]\n'
498
+ 'command = "/opt/tk/python"\n'
499
+ 'args = ["-m", "threadkeeper.server"]\n'
500
+ 'tool_timeout_sec = 120\n'
501
+ '[mcp_servers.thread-keeper.env]\n'
502
+ 'PYTHONPATH = "/opt/tk"\n'
503
+ 'SECRET_TOKEN = "do-not-copy"\n'
504
+ 'THREADKEEPER_FORCE_CID = "stale-cid"\n'
505
+ '[plugins."computer-use@openai-bundled"]\n'
506
+ 'enabled = true\n',
507
+ encoding="utf-8",
508
+ )
509
+
510
+
511
+ def test_codex_child_runs_without_the_user_codex_config(tmp_path, monkeypatch):
512
+ """Children get thread-keeper only, like Claude's --strict-mcp-config:
513
+ no other MCP server, plugin, hook or notify program from config.toml."""
514
+ pkg = _bootstrap(tmp_path, monkeypatch)
515
+ import threadkeeper.adapters.codex as codex_mod
516
+ monkeypatch.setattr(codex_mod.shutil, "which", lambda _bin: "/usr/local/bin/codex")
517
+ monkeypatch.setattr(codex_mod, "_supports_ignore_user_config", lambda _bin: True)
518
+ cfg = tmp_path / "config.toml"
519
+ _write_codex_config(cfg)
520
+ monkeypatch.setattr(pkg["codex"], "config_path", cfg)
521
+
522
+ argv = pkg["codex"].spawn_argv(
523
+ "research",
524
+ extra_allowed_tools=(
525
+ "Read,WebSearch,mcp__thread-keeper__broadcast,"
526
+ "mcp__thread-keeper__evolve_research_handoff"
527
+ ),
528
+ )
529
+
530
+ assert argv[:5] == [
531
+ "/usr/local/bin/codex", "--search", "exec", "--skip-git-repo-check",
532
+ "--ignore-user-config",
533
+ ]
534
+ overrides = _codex_overrides(argv)
535
+ text = "\n".join(overrides)
536
+ for expected in (
537
+ 'model="gpt-6-sol"',
538
+ 'model_reasoning_effort="xhigh"',
539
+ 'model_providers.corp.base_url="https://llm.example/v1"',
540
+ 'mcp_servers.thread-keeper.command="/opt/tk/python"',
541
+ 'mcp_servers.thread-keeper.args=["-m", "threadkeeper.server"]',
542
+ "mcp_servers.thread-keeper.tool_timeout_sec=120",
543
+ 'mcp_servers.thread-keeper.env.PYTHONPATH="/opt/tk"',
544
+ 'mcp_servers.thread-keeper.env.PYTHONSAFEPATH="1"',
545
+ 'mcp_servers.thread-keeper.enabled_tools=["broadcast", "evolve_research_handoff"]',
546
+ ):
547
+ assert expected in overrides, expected
548
+ for leaked in ("trello", "SECRET_TOKEN", "stale-cid", "computer-use", "notify", "personality"):
549
+ assert leaked not in text, leaked
550
+
551
+
552
+ def test_codex_child_disables_other_servers_on_older_codex(tmp_path, monkeypatch):
553
+ pkg = _bootstrap(tmp_path, monkeypatch)
554
+ import threadkeeper.adapters.codex as codex_mod
555
+ monkeypatch.setattr(codex_mod.shutil, "which", lambda _bin: "/usr/local/bin/codex")
556
+ monkeypatch.setattr(codex_mod, "_supports_ignore_user_config", lambda _bin: False)
557
+ cfg = tmp_path / "config.toml"
558
+ _write_codex_config(cfg)
559
+ monkeypatch.setattr(pkg["codex"], "config_path", cfg)
560
+
561
+ argv = pkg["codex"].spawn_argv("audit", extra_allowed_tools="mcp__thread-keeper__note")
562
+
563
+ assert "--ignore-user-config" not in argv
564
+ overrides = _codex_overrides(argv)
565
+ assert "mcp_servers.trello.enabled=false" in overrides
566
+ assert not any(o.startswith("mcp_servers.thread-keeper.enabled=") for o in overrides)
567
+ assert 'mcp_servers.thread-keeper.enabled_tools=["note"]' in overrides
568
+
569
+
570
+ def test_codex_child_synthesizes_a_thread_keeper_entry(tmp_path, monkeypatch):
571
+ import sys as _sys
572
+
573
+ pkg = _bootstrap(tmp_path, monkeypatch)
574
+ import threadkeeper.adapters.codex as codex_mod
575
+ monkeypatch.setattr(codex_mod.shutil, "which", lambda _bin: "/usr/local/bin/codex")
576
+ monkeypatch.setattr(codex_mod, "_supports_ignore_user_config", lambda _bin: True)
577
+ monkeypatch.setattr(pkg["codex"], "config_path", tmp_path / "missing.toml")
578
+
579
+ overrides = _codex_overrides(pkg["codex"].spawn_argv("audit"))
580
+
581
+ assert f"mcp_servers.thread-keeper.command={json.dumps(_sys.executable)}" in overrides
582
+ assert any(o.startswith("mcp_servers.thread-keeper.env.PYTHONPATH=") for o in overrides)
583
+
584
+
482
585
  def test_codex_iter_messages_filters_developer_turns(tmp_path, monkeypatch):
483
586
  pkg = _bootstrap(tmp_path, monkeypatch)
484
587
  fp = tmp_path / "rollout-2026-05-14T10-00-00.jsonl"
@@ -101,3 +101,38 @@ def test_wrapped_gh_honors_shared_budget_cooldown(
101
101
 
102
102
  assert rc == gb.GITHUB_RATE_COOLDOWN_EXIT
103
103
  assert not capture.exists()
104
+
105
+
106
+ def _exe(path, body):
107
+ path.write_text(body, encoding="utf-8")
108
+ path.chmod(0o700)
109
+ return path
110
+
111
+
112
+ def test_real_gh_resolution_skips_the_safety_wrapper(tmp_path, monkeypatch):
113
+ """A Codex child's MCP server gets the wrapper-first PATH without the
114
+ wrapper variables. Resolving `gh` must not find the wrapper itself, which
115
+ used to re-run itself until the dedup gate timed out."""
116
+ from threadkeeper import github_safety
117
+
118
+ wrapper_dir = tmp_path / "gh-safe-tk_x"
119
+ real_dir = tmp_path / "bin"
120
+ wrapper_dir.mkdir()
121
+ real_dir.mkdir()
122
+ wrapper = _exe(
123
+ wrapper_dir / "gh",
124
+ "#!/bin/sh\nexec python -m threadkeeper.github_safety \"$@\"\n",
125
+ )
126
+ real = _exe(real_dir / "gh", "#!/bin/sh\necho real\n")
127
+ monkeypatch.delenv("THREADKEEPER_GH_WRAPPER_DIR", raising=False)
128
+ monkeypatch.setenv("PATH", f"{wrapper_dir}:{real_dir}")
129
+
130
+ monkeypatch.delenv("THREADKEEPER_REAL_GH", raising=False)
131
+ assert github_safety._resolve_real_gh() == str(real)
132
+
133
+ monkeypatch.setenv("THREADKEEPER_REAL_GH", str(wrapper))
134
+ assert github_safety._resolve_real_gh() == str(real)
135
+
136
+ monkeypatch.setenv("PATH", str(wrapper_dir))
137
+ assert github_safety._resolve_real_gh() == ""
138
+
@@ -0,0 +1,38 @@
1
+ """Session-protocol hooks stay silent inside spawned background children."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ HOOKS = Path(__file__).resolve().parents[1] / "scripts" / "hooks"
11
+
12
+
13
+ @pytest.mark.parametrize(
14
+ "script", ["tk-brief.sh", "tk-thread-nudge.sh", "tk-session-end.sh"],
15
+ )
16
+ def test_session_hooks_exit_before_work_in_spawned_children(tmp_path, script):
17
+ marker = tmp_path / "python-ran"
18
+ fake_python = tmp_path / "python"
19
+ fake_python.write_text(f"#!/bin/sh\ntouch {marker}\n", encoding="utf-8")
20
+ fake_python.chmod(0o700)
21
+ env = {
22
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
23
+ "HOME": str(tmp_path),
24
+ "THREADKEEPER_PYTHON": str(fake_python),
25
+ "THREADKEEPER_STATE_DIR": str(tmp_path / "state"),
26
+ "THREADKEEPER_SPAWNED_CHILD": "1",
27
+ }
28
+
29
+ proc = subprocess.run(
30
+ ["bash", str(HOOKS / script)],
31
+ input='{"session_id": "s1", "prompt": "hello"}',
32
+ capture_output=True, text=True, env=env, timeout=30,
33
+ )
34
+
35
+ assert proc.returncode == 0, proc.stderr
36
+ assert proc.stdout == ""
37
+ assert not marker.exists()
38
+ assert not (tmp_path / "state").exists()
@@ -0,0 +1,101 @@
1
+ """What a spawned child inherits and which CLI runs it."""
2
+ from __future__ import annotations
3
+
4
+ _FAKE_CID = "44445555-6666-7777-8888-999900001111"
5
+
6
+
7
+ def _setup(mp_with_cid, monkeypatch, routed_cli="codex"):
8
+ pkg = mp_with_cid(_FAKE_CID)
9
+ import threadkeeper.identity as identity
10
+ import threadkeeper.spawn_config as spawn_config
11
+ import threadkeeper.tools.spawn as spawn_mod
12
+
13
+ monkeypatch.setattr(spawn_mod, "_claude_bin", lambda: "/bin/true")
14
+ monkeypatch.setattr(identity, "_active_cli", "claude")
15
+ monkeypatch.setattr(
16
+ spawn_config, "resolve_agent", lambda role, active_cli=None: routed_cli
17
+ )
18
+ monkeypatch.setattr(spawn_config, "resolve_model", lambda cli, role="": "")
19
+ launched: list[dict] = []
20
+
21
+ class _Popen:
22
+ def __init__(self, args, **kwargs):
23
+ launched.append({"args": list(args), "env": dict(kwargs.get("env") or {})})
24
+ self.pid = 4545
25
+
26
+ monkeypatch.setattr(spawn_mod.subprocess, "Popen", _Popen)
27
+ return pkg, spawn_mod, launched
28
+
29
+
30
+ def _chosen_cli(pkg):
31
+ conn = pkg["db"].get_db()
32
+ try:
33
+ return conn.execute("SELECT chosen_cli FROM tasks").fetchone()[0]
34
+ finally:
35
+ conn.close()
36
+
37
+
38
+ def test_explicit_claude_model_runs_on_claude(mp_with_cid, monkeypatch):
39
+ """`spawn(model="opus")` from a Codex-routed setup used to launch codex
40
+ with a Claude model, which the provider rejects at once."""
41
+ pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch, routed_cli="codex")
42
+
43
+ out = spawn_mod.spawn(
44
+ prompt="implement the task", cwd=str(pkg["tmp"]), model="opus",
45
+ visible=False, capture_output=False,
46
+ )
47
+
48
+ assert out.startswith("ok task="), out
49
+ assert _chosen_cli(pkg) == "claude"
50
+ args = launched[0]["args"]
51
+ assert "--model" in args and args[args.index("--model") + 1] == "opus"
52
+
53
+
54
+ def test_explicit_cli_with_a_foreign_model_is_refused(mp_with_cid, monkeypatch):
55
+ pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch)
56
+
57
+ out = spawn_mod._spawn_impl(
58
+ prompt="x", cwd=str(pkg["tmp"]), cli="codex", model="sonnet",
59
+ visible=False, capture_output=False,
60
+ )
61
+
62
+ assert out == "ERR model_cli_mismatch model=sonnet cli=codex"
63
+ assert launched == []
64
+
65
+
66
+ def test_child_env_drops_the_host_role(mp_with_cid, monkeypatch):
67
+ """The daemon host runs with THREADKEEPER_ROLE=host; a child that kept it
68
+ would start its MCP server believing it is the host."""
69
+ pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch, routed_cli="claude")
70
+ monkeypatch.setenv("THREADKEEPER_ROLE", "host")
71
+
72
+ out = spawn_mod.spawn(
73
+ prompt="x", cwd=str(pkg["tmp"]), visible=False, capture_output=False,
74
+ )
75
+
76
+ assert out.startswith("ok task="), out
77
+ env = launched[0]["env"]
78
+ assert "THREADKEEPER_ROLE" not in env
79
+ assert env["THREADKEEPER_SPAWNED_CHILD"] == "1"
80
+
81
+
82
+ def test_child_preamble_skips_the_user_session_protocol(mp_with_cid, monkeypatch):
83
+ pkg, spawn_mod, launched = _setup(mp_with_cid, monkeypatch, routed_cli="claude")
84
+
85
+ spawn_mod.spawn(prompt="x", cwd=str(pkg["tmp"]), visible=False, capture_output=False)
86
+
87
+ args = launched[0]["args"]
88
+ preamble = args[args.index("--append-system-prompt") + 1]
89
+ assert "not a user session" in preamble
90
+ assert "session_end" in preamble
91
+
92
+
93
+ def test_model_home_cli():
94
+ from threadkeeper.spawn_config import model_home_cli
95
+
96
+ assert model_home_cli("opus") == "claude"
97
+ assert model_home_cli("claude-sonnet-5") == "claude"
98
+ assert model_home_cli("gpt-5.6-terra") == "codex"
99
+ assert model_home_cli("o3") == "codex"
100
+ assert model_home_cli("gemini-3-pro") == ""
101
+ assert model_home_cli("") == ""
@@ -9,7 +9,6 @@ from __future__ import annotations
9
9
  import json
10
10
  from pathlib import Path
11
11
 
12
- import pytest
13
12
 
14
13
 
15
14
  _FAKE_CID = "11112222-3333-4444-5555-666677778888"
@@ -265,18 +264,39 @@ def test_claude_large_prompt_uses_stdin_file(mp_with_cid, monkeypatch):
265
264
  assert mode == 0o600, f"expected 0600, got {oct(mode)}"
266
265
 
267
266
 
268
- def test_spawn_slim_falls_back_to_full_config_when_unable(tmp_path, monkeypatch):
269
- """If _build_slim_mcp_config returns None (e.g. write error), spawn()
270
- must NOT crash — it just runs without slim flags."""
271
- # We unit-test by patching _build_slim_mcp_config to return None and
272
- # then checking spawn doesn't append --mcp-config to the cmd. But the
273
- # whole flow runs subprocess.Popen which we don't want in tests. Skip
274
- # full integration — the conditional branch in spawn() is plain
275
- # if-statement, no need for deep coverage.
276
- pytest.skip(
277
- "spawn() invokes claude CLI; slim-fallback path is covered by "
278
- "inspection of the conditional branch"
267
+ def test_spawn_refuses_when_the_slim_config_cannot_be_written(
268
+ mp_with_cid, monkeypatch,
269
+ ):
270
+ """A slim child whose MCP file cannot be written must not silently run
271
+ with the user's full MCP config (every configured server)."""
272
+ pkg = mp_with_cid(_FAKE_CID)
273
+ import threadkeeper.identity as identity
274
+ import threadkeeper.spawn_config as spawn_config
275
+ import threadkeeper.tools.spawn as spawn_mod
276
+
277
+ monkeypatch.setattr(spawn_mod, "_claude_bin", lambda: "/bin/true")
278
+ monkeypatch.setattr(identity, "_active_cli", "claude")
279
+ monkeypatch.setattr(
280
+ spawn_config, "resolve_agent", lambda role, active_cli=None: "claude"
279
281
  )
282
+ monkeypatch.setattr(spawn_mod, "_build_slim_mcp_config", lambda *a, **k: None)
283
+ launched = []
284
+ monkeypatch.setattr(
285
+ spawn_mod.subprocess, "Popen", lambda *a, **k: launched.append(a),
286
+ )
287
+
288
+ out = spawn_mod.spawn(
289
+ prompt="x", cwd=str(pkg["tmp"]), slim=True, visible=False,
290
+ capture_output=False,
291
+ )
292
+
293
+ assert out == "ERR slim_mcp_config_failed"
294
+ assert launched == []
295
+ conn = pkg["db"].get_db()
296
+ try:
297
+ assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0
298
+ finally:
299
+ conn.close()
280
300
 
281
301
 
282
302
  def test_review_thread_uses_slim_by_default(mp_with_cid, monkeypatch):
@@ -37,8 +37,35 @@ CHILD_MCP_ENV_KEYS: tuple[str, ...] = (
37
37
  "THREADKEEPER_CURATOR_PASS_ID",
38
38
  "THREADKEEPER_CURATOR_SNAPSHOT_DIR",
39
39
  "THREADKEEPER_EGRESS_CONSUMER",
40
+ # A privileged child's PATH starts with the gh safety wrapper; its MCP
41
+ # server needs these to reach the real gh instead of looping on itself.
42
+ "THREADKEEPER_GH_WRAPPER_DIR",
43
+ "THREADKEEPER_REAL_GH",
40
44
  )
41
45
 
46
+ # Environment a user's thread-keeper MCP entry may carry into a spawned
47
+ # child's own MCP config: package discovery plus thread-keeper knobs. Anything
48
+ # else in that entry (credentials for other tools) stays out of the child.
49
+ CHILD_MCP_ENTRY_ENV_ALLOW = frozenset({
50
+ "PYTHONPATH", "PYTHONSAFEPATH", "VIRTUAL_ENV", "PYTHONHOME",
51
+ })
52
+ CHILD_MCP_ENTRY_ENV_PREFIXES = ("THREADKEEPER_",)
53
+
54
+
55
+ def child_mcp_entry_env(env: dict | None) -> dict[str, str]:
56
+ """Filter a thread-keeper MCP entry's env for a spawned child's config.
57
+
58
+ Per-run identity keys are dropped: the child's live values must win.
59
+ """
60
+ return {
61
+ str(k): str(v) for k, v in (env or {}).items()
62
+ if (
63
+ k in CHILD_MCP_ENTRY_ENV_ALLOW
64
+ or any(str(k).startswith(p) for p in CHILD_MCP_ENTRY_ENV_PREFIXES)
65
+ )
66
+ and k not in CHILD_MCP_ENV_KEYS
67
+ }
68
+
42
69
 
43
70
  def find_cli_executable(*names: str) -> str:
44
71
  """Find a CLI even when a sandboxed macOS app has a minimal PATH."""
@@ -17,12 +17,14 @@ import os
17
17
  import re
18
18
  import shutil
19
19
  import subprocess
20
+ import sys
20
21
  from datetime import datetime
21
22
  from pathlib import Path
22
23
  from typing import Iterator
23
24
 
24
25
  from .base import (
25
- CHILD_MCP_ENV_KEYS, CLIAdapter, NormalizedMessage, find_cli_executable,
26
+ CHILD_MCP_ENV_KEYS, CLIAdapter, NormalizedMessage, child_mcp_entry_env,
27
+ find_cli_executable,
26
28
  )
27
29
  from ..config_io import mutate_text_file
28
30
 
@@ -156,6 +158,92 @@ def _approval_blocks(name: str) -> str:
156
158
  return "\n".join(lines) + "\n"
157
159
 
158
160
 
161
+ # A spawned child runs without the rest of ~/.codex/config.toml (see
162
+ # spawn_argv), but it still needs the user's provider and account settings,
163
+ # and their default model/effort for roles that do not pin their own.
164
+ _CHILD_CARRIED_CONFIG_KEYS = (
165
+ "model", "model_reasoning_effort", "model_provider", "model_providers",
166
+ "openai_base_url", "forced_login_method", "forced_chatgpt_workspace_id",
167
+ "cli_auth_credentials_store",
168
+ )
169
+ _THREAD_KEEPER_ENTRY_KEYS = (
170
+ "command", "args", "cwd", "startup_timeout_sec", "startup_timeout_ms",
171
+ "tool_timeout_sec",
172
+ )
173
+ _BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
174
+ _IGNORE_USER_CONFIG_SUPPORT: dict[str, bool] = {}
175
+
176
+
177
+ def _toml_key(key: str) -> str:
178
+ return key if _BARE_KEY_RE.fullmatch(key) else json.dumps(key)
179
+
180
+
181
+ def _toml_literal(value) -> str | None:
182
+ if isinstance(value, bool):
183
+ return "true" if value else "false"
184
+ if isinstance(value, (int, float)):
185
+ return repr(value)
186
+ if isinstance(value, str):
187
+ return json.dumps(value)
188
+ if isinstance(value, list):
189
+ items = [_toml_literal(item) for item in value]
190
+ if any(item is None for item in items):
191
+ return None
192
+ return "[" + ", ".join(items) + "]"
193
+ return None # dates and other exotic values are not carried
194
+
195
+
196
+ def _config_overrides(prefix: str, value) -> list[str]:
197
+ """`-c` strings that recreate one config value; tables are flattened."""
198
+ if isinstance(value, dict):
199
+ out: list[str] = []
200
+ for key, item in value.items():
201
+ out += _config_overrides(f"{prefix}.{_toml_key(str(key))}", item)
202
+ return out
203
+ literal = _toml_literal(value)
204
+ return [] if literal is None else [f"{prefix}={literal}"]
205
+
206
+
207
+ def _thread_keeper_entry_overrides(entry: dict) -> list[str]:
208
+ """The user's thread-keeper MCP entry, minus env the child should not get.
209
+
210
+ Without an entry the child runs the same interpreter and package as the
211
+ spawning server, like the synthesized slim config for Claude children.
212
+ """
213
+ command = entry.get("command")
214
+ if not isinstance(command, str) or not command.strip():
215
+ entry = {
216
+ "command": sys.executable,
217
+ "args": ["-m", "threadkeeper.server"],
218
+ "env": {"PYTHONPATH": str(Path(__file__).resolve().parents[2])},
219
+ }
220
+ prefix = "mcp_servers.thread-keeper"
221
+ out: list[str] = []
222
+ for key in _THREAD_KEEPER_ENTRY_KEYS:
223
+ if key in entry:
224
+ out += _config_overrides(f"{prefix}.{key}", entry[key])
225
+ for key, value in child_mcp_entry_env(entry.get("env")).items():
226
+ out.append(f"{prefix}.env.{_toml_key(key)}={json.dumps(value)}")
227
+ return out
228
+
229
+
230
+ def _supports_ignore_user_config(bin_path: str) -> bool:
231
+ """Whether this codex build has `exec --ignore-user-config` (cached)."""
232
+ cached = _IGNORE_USER_CONFIG_SUPPORT.get(bin_path)
233
+ if cached is not None:
234
+ return cached
235
+ try:
236
+ probe = subprocess.run(
237
+ [bin_path, "exec", "--help"], capture_output=True, text=True,
238
+ timeout=10, check=False,
239
+ )
240
+ except Exception:
241
+ return False # not cached: a later spawn probes again
242
+ supported = "--ignore-user-config" in (probe.stdout or "") + (probe.stderr or "")
243
+ _IGNORE_USER_CONFIG_SUPPORT[bin_path] = supported
244
+ return supported
245
+
246
+
159
247
  def _parse_toml(body: str) -> dict:
160
248
  try:
161
249
  import tomllib # py3.11+
@@ -346,6 +434,12 @@ class CodexAdapter(CLIAdapter):
346
434
  thread-keeper identity variables in ``CHILD_MCP_ENV_KEYS`` are
347
435
  forwarded by name through ``env_vars``.
348
436
 
437
+ The child runs with ``--ignore-user-config`` when the codex build has
438
+ it: only the user's provider/account keys and their thread-keeper
439
+ entry are carried over, so no other MCP server, plugin, or hook starts
440
+ inside an autonomous child. ``enabled_tools`` then exposes exactly the
441
+ granted thread-keeper tools.
442
+
349
443
  `extra_allowed_tools` is the child's full Claude-style allowlist.
350
444
  `codex exec` runs with approval policy "never", so a thread-keeper
351
445
  write tool that is not pre-approved fails with "MCP tool call requires
@@ -371,29 +465,54 @@ class CodexAdapter(CLIAdapter):
371
465
  if {"websearch", "webfetch"} & requested_tools:
372
466
  argv.append("--search")
373
467
  argv += ["exec", "--skip-git-repo-check"]
468
+ user_config = _read_toml(self.config_path)
469
+ overrides: list[str] = []
470
+ # Like Claude's --strict-mcp-config: the child gets thread-keeper and
471
+ # nothing else from the user's setup. With the user config loaded,
472
+ # every configured MCP server, plugin (computer use, browser, Drive)
473
+ # and hook started inside autonomous children, including the web
474
+ # researcher that reads untrusted pages.
475
+ if _supports_ignore_user_config(bin_path):
476
+ argv.append("--ignore-user-config")
477
+ for key in _CHILD_CARRIED_CONFIG_KEYS:
478
+ if key in user_config:
479
+ overrides += _config_overrides(_toml_key(key), user_config[key])
480
+ entry = (user_config.get("mcp_servers") or {}).get("thread-keeper")
481
+ overrides += _thread_keeper_entry_overrides(
482
+ entry if isinstance(entry, dict) else {}
483
+ )
484
+ else:
485
+ for name in (user_config.get("mcp_servers") or {}):
486
+ if name != "thread-keeper":
487
+ overrides.append(f"mcp_servers.{_toml_key(name)}.enabled=false")
374
488
  # Codex launches configured stdio MCP servers from the agent cwd. A
375
489
  # repository checkout can therefore shadow the installed package used
376
490
  # by thread-keeper's MCP entry. Scope safe-path mode to the MCP server
377
491
  # only; shell/test Python processes in the child keep normal semantics.
378
- argv += [
379
- "-c",
380
- 'mcp_servers.thread-keeper.env.PYTHONSAFEPATH="1"',
381
- ]
492
+ safepath = 'mcp_servers.thread-keeper.env.PYTHONSAFEPATH="1"'
493
+ if safepath not in overrides:
494
+ overrides.append(safepath)
382
495
  # Codex also starts MCP servers with a scrubbed environment, so the
383
496
  # child's thread-keeper server would not know it is this child: no
384
497
  # forced cid, write origin, or Curator pass, and every report write
385
498
  # was refused as unauthorized. Forward those names from the child's
386
499
  # own environment; the values never land on argv.
387
- argv += [
388
- "-c",
500
+ overrides.append(
389
501
  "mcp_servers.thread-keeper.env_vars="
390
- + json.dumps(list(CHILD_MCP_ENV_KEYS)),
502
+ + json.dumps(list(CHILD_MCP_ENV_KEYS))
503
+ )
504
+ # Expose exactly the granted tools, as --allowedTools does for Claude;
505
+ # approvals alone left every tool the user pre-approved callable.
506
+ granted = _granted_thread_keeper_tools(extra_allowed_tools)
507
+ overrides.append(
508
+ "mcp_servers.thread-keeper.enabled_tools=" + json.dumps(granted)
509
+ )
510
+ overrides += [
511
+ f'mcp_servers.thread-keeper.tools.{tool}.approval_mode="approve"'
512
+ for tool in granted
391
513
  ]
392
- for tool in _granted_thread_keeper_tools(extra_allowed_tools):
393
- argv += [
394
- "-c",
395
- f'mcp_servers.thread-keeper.tools.{tool}.approval_mode="approve"',
396
- ]
514
+ for override in overrides:
515
+ argv += ["-c", override]
397
516
  if model:
398
517
  argv += ["-m", model]
399
518
  if effort:
@@ -170,17 +170,34 @@ def sanitize_gh_body_args(args: Sequence[str]) -> tuple[list[str], list[Path]]:
170
170
  return out, cleanup
171
171
 
172
172
 
173
+ def _is_safety_wrapper(path: str) -> bool:
174
+ """Whether `path` is one of spawn()'s gh wrapper scripts, not gh itself."""
175
+ try:
176
+ with open(path, "rb") as fh:
177
+ return b"threadkeeper.github_safety" in fh.read(512)
178
+ except OSError:
179
+ return False
180
+
181
+
173
182
  def _resolve_real_gh() -> str:
183
+ """The real gh binary, never a safety wrapper.
184
+
185
+ A child's MCP server can inherit the wrapper-first PATH without
186
+ THREADKEEPER_REAL_GH (Codex scrubs MCP server environments). Taking the
187
+ first `gh` on PATH then found the wrapper itself, which re-ran itself
188
+ until the caller's timeout, so every issue dedup check failed.
189
+ """
174
190
  env = os.environ.get("THREADKEEPER_REAL_GH", "").strip()
175
- if env:
191
+ if env and not _is_safety_wrapper(env):
176
192
  return env
177
193
  wrapper_dir = os.environ.get("THREADKEEPER_GH_WRAPPER_DIR", "").strip()
178
- path_parts = [
179
- p for p in os.environ.get("PATH", "").split(os.pathsep)
180
- if p and Path(p) != Path(wrapper_dir)
181
- ]
182
- found = shutil.which("gh", path=os.pathsep.join(path_parts))
183
- return found or ""
194
+ for part in os.environ.get("PATH", "").split(os.pathsep):
195
+ if not part or (wrapper_dir and Path(part) == Path(wrapper_dir)):
196
+ continue
197
+ found = shutil.which("gh", path=part)
198
+ if found and not _is_safety_wrapper(found):
199
+ return found
200
+ return ""
184
201
 
185
202
 
186
203
  def run_wrapped_gh(args: Sequence[str], real_gh: str | None = None) -> int: