code-context-control 2.38.1__tar.gz → 2.39.1__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 (223) hide show
  1. {code_context_control-2.38.1/code_context_control.egg-info → code_context_control-2.39.1}/PKG-INFO +1 -1
  2. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/_hook_utils.py +39 -2
  3. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/c3.py +60 -35
  4. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_edit_ledger.py +9 -3
  5. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_edit_unlock.py +9 -1
  6. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_pretool_enforce.py +23 -7
  7. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/delegate.py +106 -22
  8. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/edit.py +65 -18
  9. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/memory.py +4 -0
  10. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/read.py +27 -5
  11. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/validate.py +43 -15
  12. {code_context_control-2.38.1 → code_context_control-2.39.1/code_context_control.egg-info}/PKG-INFO +1 -1
  13. {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/SOURCES.txt +5 -0
  14. {code_context_control-2.38.1 → code_context_control-2.39.1}/core/mcp_toml.py +29 -2
  15. {code_context_control-2.38.1 → code_context_control-2.39.1}/core/web_security.py +6 -0
  16. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/mcp_oracle.py +18 -3
  17. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/oracle_server.py +54 -14
  18. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/activity_reporter.py +29 -4
  19. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/c3_bridge.py +29 -2
  20. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/chat_engine.py +6 -0
  21. {code_context_control-2.38.1 → code_context_control-2.39.1}/pyproject.toml +1 -1
  22. code_context_control-2.39.1/services/circuit_breaker.py +86 -0
  23. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/claude_md.py +26 -11
  24. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/compressor.py +5 -1
  25. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/context_snapshot.py +39 -9
  26. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/conversation_store.py +99 -48
  27. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/edit_ledger.py +58 -27
  28. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/file_memory.py +77 -6
  29. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/parser.py +32 -6
  30. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_activity_reporter.py +15 -0
  31. code_context_control-2.39.1/tests/test_circuit_breaker.py +103 -0
  32. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_claude_md_merge.py +39 -0
  33. code_context_control-2.39.1/tests/test_edit_ledger_hook.py +88 -0
  34. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_edit_normalization.py +121 -0
  35. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_enforcement_flip.py +67 -2
  36. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_mcp_toml.py +39 -0
  37. code_context_control-2.39.1/tests/test_oracle_security_fixes.py +159 -0
  38. code_context_control-2.39.1/tests/test_service_durability.py +186 -0
  39. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_web_security.py +31 -0
  40. {code_context_control-2.38.1 → code_context_control-2.39.1}/LICENSE +0 -0
  41. {code_context_control-2.38.1 → code_context_control-2.39.1}/README.md +0 -0
  42. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/__init__.py +0 -0
  43. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/commands/__init__.py +0 -0
  44. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/commands/common.py +0 -0
  45. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/commands/parser.py +0 -0
  46. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/docs.html +0 -0
  47. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/edits.html +0 -0
  48. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/bitbucket.html +0 -0
  49. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/getting-started.html +0 -0
  50. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/index.html +0 -0
  51. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/oracle.html +0 -0
  52. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/shared.css +0 -0
  53. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/tools.html +0 -0
  54. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/guide/workflow.html +0 -0
  55. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_auto_snapshot.py +0 -0
  56. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_c3_signal.py +0 -0
  57. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_c3read.py +0 -0
  58. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_filter.py +0 -0
  59. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_ghost_files.py +0 -0
  60. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_read.py +0 -0
  61. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_session_stats.py +0 -0
  62. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hook_terse_advisor.py +0 -0
  63. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hub.html +0 -0
  64. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/hub_server.py +0 -0
  65. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/mcp_proxy.py +0 -0
  66. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/mcp_server.py +0 -0
  67. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/server.py +0 -0
  68. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/__init__.py +0 -0
  69. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/_helpers.py +0 -0
  70. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/agent.py +0 -0
  71. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/bitbucket.py +0 -0
  72. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/compress.py +0 -0
  73. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/edits.py +0 -0
  74. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/filter.py +0 -0
  75. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/impact.py +0 -0
  76. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/project.py +0 -0
  77. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/search.py +0 -0
  78. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/session.py +0 -0
  79. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/shell.py +0 -0
  80. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/tools/status.py +0 -0
  81. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/api.js +0 -0
  82. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/app.js +0 -0
  83. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/bitbucket.js +0 -0
  84. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/chat.js +0 -0
  85. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/dashboard.js +0 -0
  86. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/edits.js +0 -0
  87. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/instructions.js +0 -0
  88. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/memory.js +0 -0
  89. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/sessions.js +0 -0
  90. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/settings.js +0 -0
  91. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/components/sidebar.js +0 -0
  92. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/icons.js +0 -0
  93. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/shared.js +0 -0
  94. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui/theme.js +0 -0
  95. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui.html +0 -0
  96. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui_legacy.html +0 -0
  97. {code_context_control-2.38.1 → code_context_control-2.39.1}/cli/ui_nano.html +0 -0
  98. {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/dependency_links.txt +0 -0
  99. {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/entry_points.txt +0 -0
  100. {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/requires.txt +0 -0
  101. {code_context_control-2.38.1 → code_context_control-2.39.1}/code_context_control.egg-info/top_level.txt +0 -0
  102. {code_context_control-2.38.1 → code_context_control-2.39.1}/core/__init__.py +0 -0
  103. {code_context_control-2.38.1 → code_context_control-2.39.1}/core/config.py +0 -0
  104. {code_context_control-2.38.1 → code_context_control-2.39.1}/core/ide.py +0 -0
  105. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/__init__.py +0 -0
  106. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/config.py +0 -0
  107. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/oracle.html +0 -0
  108. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/__init__.py +0 -0
  109. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/api_auth.py +0 -0
  110. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/chat_store.py +0 -0
  111. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/cross_memory.py +0 -0
  112. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/federated_graph.py +0 -0
  113. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/health_checker.py +0 -0
  114. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/insight_engine.py +0 -0
  115. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/memory_reader.py +0 -0
  116. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/memory_writer.py +0 -0
  117. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/ollama_bridge.py +0 -0
  118. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/project_scanner.py +0 -0
  119. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/review_agent.py +0 -0
  120. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/tool_executor.py +0 -0
  121. {code_context_control-2.38.1 → code_context_control-2.39.1}/oracle/services/tool_registry.py +0 -0
  122. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/__init__.py +0 -0
  123. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/activity_log.py +0 -0
  124. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/agent_base.py +0 -0
  125. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/agents.py +0 -0
  126. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/auto_memory.py +0 -0
  127. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/__init__.py +0 -0
  128. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/external/__init__.py +0 -0
  129. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/external/aider_polyglot.py +0 -0
  130. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bench/external/swe_bench.py +0 -0
  131. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/benchmark_dashboard.py +0 -0
  132. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bitbucket_client.py +0 -0
  133. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/bitbucket_credentials.py +0 -0
  134. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/doc_index.py +0 -0
  135. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/e2e_benchmark.py +0 -0
  136. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/e2e_evaluator.py +0 -0
  137. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/e2e_tasks.py +0 -0
  138. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/embedding_index.py +0 -0
  139. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/error_reporting.py +0 -0
  140. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/git_context.py +0 -0
  141. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/hub_service.py +0 -0
  142. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/indexer.py +0 -0
  143. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory.py +0 -0
  144. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_consolidator.py +0 -0
  145. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_graph.py +0 -0
  146. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_grounder.py +0 -0
  147. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/memory_scorer.py +0 -0
  148. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/metrics.py +0 -0
  149. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/notifications.py +0 -0
  150. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/ollama_client.py +0 -0
  151. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/output_filter.py +0 -0
  152. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/project_manager.py +0 -0
  153. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/project_runtime.py +0 -0
  154. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/protocol.py +0 -0
  155. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/proxy_state.py +0 -0
  156. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/retrieval_broker.py +0 -0
  157. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/router.py +0 -0
  158. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/runtime.py +0 -0
  159. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/session_benchmark.py +0 -0
  160. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/session_manager.py +0 -0
  161. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/session_preloader.py +0 -0
  162. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/text_index.py +0 -0
  163. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/tool_classifier.py +0 -0
  164. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/transcript_index.py +0 -0
  165. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/validation_cache.py +0 -0
  166. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/vector_store.py +0 -0
  167. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/version_tracker.py +0 -0
  168. {code_context_control-2.38.1 → code_context_control-2.39.1}/services/watcher.py +0 -0
  169. {code_context_control-2.38.1 → code_context_control-2.39.1}/setup.cfg +0 -0
  170. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_aider_polyglot.py +0 -0
  171. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_cli_smoke.py +0 -0
  172. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_client.py +0 -0
  173. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_credentials.py +0 -0
  174. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_bitbucket_tool.py +0 -0
  175. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_c3_shell.py +0 -0
  176. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_cli_smoke.py +0 -0
  177. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_e2e_benchmark.py +0 -0
  178. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_federated_graph.py +0 -0
  179. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_ghost_files.py +0 -0
  180. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_git_branch_awareness.py +0 -0
  181. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_hub_server_smoke.py +0 -0
  182. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_install_mcp_entrypoint.py +0 -0
  183. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_lazy_store_init.py +0 -0
  184. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_mcp_host_guard.py +0 -0
  185. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_mcp_server_smoke.py +0 -0
  186. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_memory_graph_api.py +0 -0
  187. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_memory_system.py +0 -0
  188. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_notification_discipline.py +0 -0
  189. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_oracle_api_auth.py +0 -0
  190. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_oracle_apikey_api.py +0 -0
  191. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_oracle_discovery_api.py +0 -0
  192. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_output_filter.py +0 -0
  193. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_permissions.py +0 -0
  194. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_project_manager.py +0 -0
  195. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_project_manager_merge.py +0 -0
  196. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_project_tool.py +0 -0
  197. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_read_coercion.py +0 -0
  198. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_session_benchmark.py +0 -0
  199. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_session_budget.py +0 -0
  200. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_shell_robustness.py +0 -0
  201. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_swe_bench.py +0 -0
  202. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_tool_registry.py +0 -0
  203. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_upgrade_and_version.py +0 -0
  204. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_validate.py +0 -0
  205. {code_context_control-2.38.1 → code_context_control-2.39.1}/tests/test_windows_reliability.py +0 -0
  206. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/__init__.py +0 -0
  207. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/backend.py +0 -0
  208. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/main.py +0 -0
  209. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/__init__.py +0 -0
  210. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/benchmark_view.py +0 -0
  211. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/claudemd_view.py +0 -0
  212. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/compress_view.py +0 -0
  213. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/index_view.py +0 -0
  214. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/init_view.py +0 -0
  215. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/mcp_view.py +0 -0
  216. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/optimize_view.py +0 -0
  217. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/pipe_view.py +0 -0
  218. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/projects_view.py +0 -0
  219. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/search_view.py +0 -0
  220. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/session_view.py +0 -0
  221. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/stats.py +0 -0
  222. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/screens/ui_view.py +0 -0
  223. {code_context_control-2.38.1 → code_context_control-2.39.1}/tui/theme.tcss +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.38.1
3
+ Version: 2.39.1
4
4
  Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -74,9 +74,46 @@ def get_tool_output(data: dict) -> tuple:
74
74
 
75
75
 
76
76
  def get_tool_input_path(data: dict) -> str:
77
- """Extract file path from tool_input, handling both Claude (file_path) and Gemini (path)."""
77
+ """Extract file path from tool_input, handling Claude (file_path),
78
+ Gemini (path), and NotebookEdit (notebook_path)."""
78
79
  tool_input = data.get("tool_input", {})
79
- return tool_input.get("file_path", "") or tool_input.get("path", "")
80
+ return (
81
+ tool_input.get("file_path", "")
82
+ or tool_input.get("path", "")
83
+ or tool_input.get("notebook_path", "")
84
+ )
85
+
86
+
87
+ def record_json_unlocks(editable: list, project_path: Path | None = None) -> None:
88
+ """Record file paths as read+edit unlocked in .c3/unlocked_files.json.
89
+
90
+ This is the map that hook_pretool_enforce.py actually reads (the plain
91
+ .txt unlock list is not consumed by any hook). Mirrors the behaviour of
92
+ cli/hook_c3read._record_json_unlocks so c3_compress/c3_agent sticky
93
+ unlocks reach the enforcer. Fails silently on I/O errors.
94
+ """
95
+ base = project_path if project_path is not None else Path.cwd()
96
+ json_path = base / ".c3" / "unlocked_files.json"
97
+ try:
98
+ existing: dict = {}
99
+ if json_path.exists():
100
+ try:
101
+ existing = json.loads(json_path.read_text(encoding="utf-8"))
102
+ if not isinstance(existing, dict):
103
+ existing = {}
104
+ except Exception:
105
+ existing = {}
106
+ for fp in editable:
107
+ if not fp:
108
+ continue
109
+ normalized = str(Path(fp).resolve())
110
+ cats = set(existing.get(normalized, []))
111
+ cats.update({"read", "edit"})
112
+ existing[normalized] = sorted(cats)
113
+ json_path.parent.mkdir(parents=True, exist_ok=True)
114
+ json_path.write_text(json.dumps(existing), encoding="utf-8")
115
+ except Exception:
116
+ pass
80
117
 
81
118
 
82
119
  def emit_additional_context(text: str, is_gemini: bool) -> None:
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
85
85
  # Config
86
86
  CONFIG_DIR = ".c3"
87
87
  CONFIG_FILE = ".c3/config.json"
88
- __version__ = "2.38.1"
88
+ __version__ = "2.39.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
@@ -4084,7 +4084,11 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
4084
4084
  content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
4085
4085
  header = f"[{section}]"
4086
4086
 
4087
- # Strip existing section (header + its key=value lines)
4087
+ # Strip existing section (header + its key=value lines). Also strip any
4088
+ # dotted child subtables (e.g. "[mcp_servers.c3.env]" under
4089
+ # "[mcp_servers.c3]") so they are not orphaned beneath the re-appended
4090
+ # section, which would corrupt the file on re-run.
4091
+ child_prefix = f"{header[:-1]}." # "[mcp_servers.c3]" -> "[mcp_servers.c3."
4088
4092
  lines = content.splitlines()
4089
4093
  new_lines: list[str] = []
4090
4094
  skip = False
@@ -4094,7 +4098,8 @@ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
4094
4098
  skip = True
4095
4099
  continue
4096
4100
  if skip and stripped.startswith("["):
4097
- skip = False
4101
+ if not stripped.startswith(child_prefix):
4102
+ skip = False
4098
4103
  if not skip:
4099
4104
  new_lines.append(line)
4100
4105
 
@@ -4577,41 +4582,48 @@ def _ensure_global_claude_md() -> None:
4577
4582
 
4578
4583
  existing = global_md.read_text(encoding="utf-8")
4579
4584
 
4580
- if _GLOBAL_CLAUDE_MD_MARKER not in existing:
4581
- # User has their own CLAUDE.md append C3 section
4582
- merged = existing.rstrip() + "\n\n" + _GLOBAL_CLAUDE_MD_CONTENT
4583
- global_md.write_text(merged, encoding="utf-8")
4584
- print(f"Updated {global_md} (appended C3 enforcement)")
4585
- return
4585
+ # The C3-managed region is delimited by explicit BEGIN/END sentinels (the
4586
+ # same ones used for project instruction docs). This is unambiguous, so
4587
+ # user-written content outside the markers — including H1 headings that
4588
+ # happen to mention "C3" or "Tool Discipline" — is never swallowed.
4589
+ from services.claude_md import C3_BLOCK_BEGIN, C3_BLOCK_END, merge_c3_block
4586
4590
 
4587
- # C3 section exists — replace it with the latest version
4588
- # Find the C3 section boundaries: starts at the marker, ends at next # heading or EOF
4589
- start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
4590
- # Find the next top-level heading after the C3 section
4591
- rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
4592
- lines_after = rest.split("\n")
4593
- end_offset = len(rest) # default: to EOF
4594
- running = 0
4595
- for line in lines_after:
4596
- running += len(line) + 1
4597
- # A top-level heading that's NOT part of C3's sub-headings
4598
- if line.startswith("# ") and "C3" not in line and "Tool Discipline" not in line:
4599
- end_offset = running - len(line) - 1
4600
- break
4601
-
4602
- end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
4603
- before = existing[:start].rstrip()
4604
- after = existing[end:].lstrip()
4591
+ wrapped = f"{C3_BLOCK_BEGIN}\n{_GLOBAL_CLAUDE_MD_CONTENT.strip()}\n{C3_BLOCK_END}"
4605
4592
 
4606
- parts = []
4607
- if before:
4608
- parts.append(before)
4609
- parts.append(_GLOBAL_CLAUDE_MD_CONTENT.strip())
4610
- if after:
4611
- parts.append(after)
4593
+ # Markers already present → surgical, marker-bounded replacement.
4594
+ if C3_BLOCK_BEGIN in existing:
4595
+ global_md.write_text(merge_c3_block(existing, wrapped), encoding="utf-8")
4596
+ print(f"Updated {global_md} (refreshed C3 enforcement)")
4597
+ return
4612
4598
 
4613
- global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
4614
- print(f"Updated {global_md} (refreshed C3 enforcement)")
4599
+ # Legacy marker-less C3 region → one-time migration into the marked block.
4600
+ # Bound the region from the legacy heading to the NEXT top-level (``# ``)
4601
+ # heading. C3's own content has exactly one H1 (the legacy heading itself),
4602
+ # so the next H1 reliably marks where user content resumes; we deliberately
4603
+ # do NOT skip H1s containing "C3"/"Tool Discipline" (the old heuristic did,
4604
+ # which is what swallowed user headings).
4605
+ if _GLOBAL_CLAUDE_MD_MARKER in existing:
4606
+ start = existing.index(_GLOBAL_CLAUDE_MD_MARKER)
4607
+ rest = existing[start + len(_GLOBAL_CLAUDE_MD_MARKER):]
4608
+ end_offset = len(rest) # default: to EOF
4609
+ running = 0
4610
+ for line in rest.split("\n"):
4611
+ running += len(line) + 1
4612
+ if line.startswith("# "):
4613
+ end_offset = running - len(line) - 1
4614
+ break
4615
+ end = start + len(_GLOBAL_CLAUDE_MD_MARKER) + end_offset
4616
+ before = existing[:start].rstrip()
4617
+ after = existing[end:].lstrip()
4618
+ parts = [p for p in (before, wrapped, after) if p]
4619
+ global_md.write_text("\n\n".join(parts) + "\n", encoding="utf-8")
4620
+ print(f"Updated {global_md} (migrated C3 enforcement to markers)")
4621
+ return
4622
+
4623
+ # User has their own CLAUDE.md with no C3 content — append the marked block.
4624
+ merged = existing.rstrip() + "\n\n" + wrapped + "\n"
4625
+ global_md.write_text(merged, encoding="utf-8")
4626
+ print(f"Updated {global_md} (appended C3 enforcement)")
4615
4627
 
4616
4628
 
4617
4629
  def _instruction_documents_for_project() -> list[tuple[str, str]]:
@@ -5019,6 +5031,8 @@ def cmd_install_mcp(args):
5019
5031
  glob_matcher = "find_files"
5020
5032
  edit_matcher = "edit_file"
5021
5033
  write_matcher = "write_file"
5034
+ # Gemini has no MultiEdit / NotebookEdit equivalents.
5035
+ extra_edit_matchers = []
5022
5036
  else:
5023
5037
  shell_matcher = "Bash"
5024
5038
  read_matcher = "Read"
@@ -5026,6 +5040,9 @@ def cmd_install_mcp(args):
5026
5040
  glob_matcher = "Glob"
5027
5041
  edit_matcher = "Edit"
5028
5042
  write_matcher = "Write"
5043
+ # Claude Code also exposes MultiEdit (batch edits) and NotebookEdit;
5044
+ # both bypass enforcement/logging unless their matchers are registered.
5045
+ extra_edit_matchers = ["MultiEdit", "NotebookEdit"]
5029
5046
 
5030
5047
  # ── PostToolUse hooks ──
5031
5048
  desired_post_hooks = [
@@ -5120,6 +5137,10 @@ def cmd_install_mcp(args):
5120
5137
  "matcher": write_matcher,
5121
5138
  "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]
5122
5139
  },
5140
+ *[
5141
+ {"matcher": m, "hooks": [{"type": "command", "command": hook_edit_ledger_cmd}]}
5142
+ for m in extra_edit_matchers
5143
+ ],
5123
5144
  ]
5124
5145
 
5125
5146
  # ── PreToolUse hooks (enforcement — blocks native tools without prior c3_*) ──
@@ -5144,6 +5165,10 @@ def cmd_install_mcp(args):
5144
5165
  "matcher": write_matcher,
5145
5166
  "hooks": [{"type": "command", "command": hook_enforce_cmd}]
5146
5167
  },
5168
+ *[
5169
+ {"matcher": m, "hooks": [{"type": "command", "command": hook_enforce_cmd}]}
5170
+ for m in extra_edit_matchers
5171
+ ],
5147
5172
  ]
5148
5173
 
5149
5174
  # Merge: replace existing C3 hooks (so re-running install-mcp updates commands),
@@ -10,6 +10,7 @@ EditLedgerEnricherAgent running in the MCP server background.
10
10
 
11
11
  import json
12
12
  import sys
13
+ import uuid
13
14
  from datetime import datetime, timezone
14
15
  from pathlib import Path
15
16
 
@@ -25,7 +26,7 @@ EDITABLE_EXTS = {
25
26
  ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
26
27
  ".rb", ".c", ".cpp", ".h", ".cs", ".html", ".css",
27
28
  ".json", ".yaml", ".yml", ".toml", ".sql", ".md", ".txt",
28
- ".sh", ".bat", ".ps1", ".r",
29
+ ".sh", ".bat", ".ps1", ".r", ".ipynb",
29
30
  }
30
31
 
31
32
  # How many tail lines to scan for version/seq (avoids full-file parse)
@@ -95,8 +96,11 @@ def _next_seq(ledger_file: Path, now: datetime) -> int:
95
96
  continue
96
97
  eid = entry.get("id", "")
97
98
  if eid.startswith(prefix):
99
+ # ids may carry a random suffix ("..._001_a1b2"); take the leading
100
+ # numeric run after the prefix so same-second seq counting survives.
101
+ seq_part = eid[len(prefix):].split("_", 1)[0]
98
102
  try:
99
- max_seq = max(max_seq, int(eid[len(prefix):]))
103
+ max_seq = max(max_seq, int(seq_part))
100
104
  except ValueError:
101
105
  pass
102
106
  return max_seq + 1
@@ -172,7 +176,9 @@ def main():
172
176
  git_pending = tracking_level != "minimal"
173
177
 
174
178
  entry = {
175
- "id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}",
179
+ # Random suffix prevents id collisions when the hook process and the
180
+ # server process (services/edit_ledger.py) write within the same second.
181
+ "id": f"edit_{now.strftime('%Y%m%d_%H%M%S')}_{_next_seq(ledger_file, now):03d}_{uuid.uuid4().hex[:4]}",
176
182
  "timestamp": now.isoformat(),
177
183
  "session_id": "",
178
184
  "file": rel,
@@ -14,7 +14,11 @@ from pathlib import Path
14
14
 
15
15
  sys.path.insert(0, str(Path(__file__).parent.parent))
16
16
 
17
- from cli._hook_utils import emit_additional_context, log_hook_error # noqa: E402
17
+ from cli._hook_utils import ( # noqa: E402
18
+ emit_additional_context,
19
+ log_hook_error,
20
+ record_json_unlocks,
21
+ )
18
22
 
19
23
  EDITABLE_EXTS = {
20
24
  ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java",
@@ -144,6 +148,10 @@ def main():
144
148
  except Exception:
145
149
  pass
146
150
 
151
+ # Fix 2: also write the .json unlock map — the .txt list above is read
152
+ # by NO hook; hook_pretool_enforce.py only consults unlocked_files.json.
153
+ record_json_unlocks(editable)
154
+
147
155
  # Emit batched nudge with all pending files
148
156
  # Prefer c3_edit (no unlock needed). Native Edit is also unlocked via sticky file set.
149
157
  if len(pending) == 1:
@@ -180,24 +180,30 @@ def _is_file_unlocked(project_path: Path, file_path: str, category: str) -> bool
180
180
  return category in cats or "both" in cats
181
181
 
182
182
 
183
- def _check_signal_file(project_path: Path) -> tuple[bool, bool]:
183
+ def _check_signal_file(project_path: Path) -> tuple[bool, bool, str]:
184
184
  """Read last_c3_call.json written by hook_c3_signal.py.
185
185
 
186
- Returns (recent, read_unlocked):
186
+ Returns (recent, read_unlocked, c3_tool):
187
187
  recent: True if a c3_* tool completed within _SIGNAL_MAX_AGE_SECS
188
188
  read_unlocked: True if that tool was c3_search/c3_compress/c3_filter
189
+ c3_tool: short name of the c3 tool that wrote the signal (e.g.
190
+ "c3_edit"), or "" if recent is False / unparseable.
191
+
192
+ Fails closed: on any parse error, returns (False, False, "").
189
193
  """
190
194
  signal_path = project_path / _SIGNAL_FILE
191
195
  if not signal_path.exists():
192
- return False, False
196
+ return False, False, ""
193
197
  try:
194
198
  data = json.loads(signal_path.read_text(encoding="utf-8"))
195
199
  ts = datetime.fromisoformat(data["timestamp"])
196
200
  age = (datetime.now(timezone.utc) - ts).total_seconds()
197
201
  recent = age <= _SIGNAL_MAX_AGE_SECS
198
- return recent, bool(data.get("read_unlocked", False)) and recent
202
+ if not recent:
203
+ return False, False, ""
204
+ return True, bool(data.get("read_unlocked", False)), str(data.get("tool", ""))
199
205
  except Exception:
200
- return False, False
206
+ return False, False, ""
201
207
 
202
208
 
203
209
  def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tuple[bool, str]:
@@ -223,10 +229,20 @@ def _check_c3_used(project_path: Path, tool_name: str, tool_input: dict) -> tupl
223
229
  required_cat = _TOOL_CATEGORY.get(tool_name, "read")
224
230
 
225
231
  # ── Fix 4: signal file — primary, fast, reliable ─────────────────────────
226
- signal_recent, signal_read_unlocked = _check_signal_file(project_path)
232
+ signal_recent, signal_read_unlocked, signal_tool = _check_signal_file(project_path)
227
233
  if signal_recent:
234
+ # Bypass fix: for write-class tools (Edit/Write/MultiEdit), the signal
235
+ # may only unlock them when the c3 tool that wrote it actually satisfies
236
+ # this tool's prereqs (e.g. c3_edit/c3_edits/c3_agent). A read-class
237
+ # signal (c3_status, c3_search, …) must NOT unlock a native write.
238
+ if tool_name in _BLOCKED_TOOLS:
239
+ if signal_tool in allowed:
240
+ if native_target:
241
+ _record_unlock(project_path, native_target, required_cat)
242
+ return True, "signal"
243
+ # Fresh signal exists but it's not a write-prereq tool — fall through
228
244
  # Fix 5: Grep/Glob without file path needs a read-unlocking tool
229
- if not native_target and tool_name in ("Grep", "Glob", "FindFiles", "SearchText"):
245
+ elif not native_target and tool_name in ("Grep", "Glob", "FindFiles", "SearchText"):
230
246
  if signal_read_unlocked:
231
247
  return True, "signal"
232
248
  # Signal exists but not read-unlocking (e.g. c3_memory) — fall through
@@ -11,10 +11,12 @@ import os
11
11
  import shutil
12
12
  import subprocess
13
13
  import sys
14
+ import threading
14
15
  import time
15
16
  from pathlib import Path
16
17
 
17
18
  from core import count_tokens
19
+ from services.circuit_breaker import CircuitBreaker
18
20
 
19
21
  log = logging.getLogger(__name__)
20
22
 
@@ -40,6 +42,7 @@ def _kill_proc_tree(proc):
40
42
  subprocess.run(
41
43
  ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
42
44
  capture_output=True, stdin=subprocess.DEVNULL,
45
+ creationflags=subprocess.CREATE_NO_WINDOW,
43
46
  )
44
47
  else:
45
48
  proc.kill()
@@ -51,8 +54,10 @@ def _kill_proc_tree(proc):
51
54
  def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
52
55
  """communicate() replacement with idle-activity watchdog.
53
56
 
54
- Monitors stderr for activity. If no stderr output for idle_timeout seconds,
55
- kills the process early (catches MCP startup hangs). Also enforces total timeout.
57
+ Monitors both stdout and stderr for activity. If neither stream produces
58
+ output for idle_timeout seconds, kills the process early (catches MCP startup
59
+ hangs) without killing a backend that streams its answer only on stdout.
60
+ Also enforces total timeout.
56
61
 
57
62
  Returns (stdout, stderr, status) where status is 'ok', 'timeout', or 'idle_timeout'.
58
63
  """
@@ -71,7 +76,7 @@ def _communicate_with_heartbeat(proc, timeout=45, idle_timeout=15):
71
76
  except (ValueError, OSError):
72
77
  pass
73
78
 
74
- t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
79
+ t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
75
80
  t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
76
81
  t_out.start()
77
82
  t_err.start()
@@ -218,10 +223,17 @@ def _run_claude(task: str, context: str, cwd: str | None = None,
218
223
  cmd,
219
224
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
220
225
  stdin=subprocess.DEVNULL,
221
- text=True, cwd=cwd,
226
+ text=True, encoding="utf-8", errors="replace", cwd=cwd,
222
227
  **_popen_kwargs(),
223
228
  )
224
- output, err = _communicate_with_heartbeat(proc, timeout=timeout, idle_timeout=idle_timeout)
229
+ output, err, status = _communicate_with_heartbeat(
230
+ proc, timeout=timeout, idle_timeout=idle_timeout,
231
+ )
232
+ if status == "idle_timeout":
233
+ return (f"[claude:idle_timeout] No stderr activity for {idle_timeout}s "
234
+ f"(likely MCP startup hang)"), False
235
+ if status == "timeout":
236
+ return f"[claude:timeout] No response after {timeout}s", False
225
237
  if proc.returncode == 0 and output.strip():
226
238
  return output.strip(), True
227
239
  return f"[claude:error] {(err or '').strip() or 'no output'}", False
@@ -242,11 +254,20 @@ def _handle_claude_delegate(task: str, task_type: str, context: str,
242
254
  file_path: str, svc, dcfg: dict, finalize) -> str:
243
255
  """Handle delegation via Claude Code CLI."""
244
256
  timeout = int(dcfg.get("claude_timeout", 90))
257
+ breaker = _backend_breaker("claude", dcfg)
258
+ if not breaker.allow():
259
+ return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
260
+ "[delegate:degraded] Claude skipped after repeated failures; retrying in "
261
+ f"~{breaker.cooldown_remaining()}s. Run 'claude --version' to diagnose.",
262
+ "degraded")
245
263
  _log_progress(svc, f"[delegate] Routing {task_type} → Claude CLI...")
246
264
  output, ok = _run_claude(task, context, cwd=str(svc.project_path), timeout=timeout)
247
265
  if not ok:
266
+ if breaker.record_failure():
267
+ _notify_backend_degraded(svc, "claude", breaker)
248
268
  return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
249
269
  output, "error")
270
+ breaker.record_success()
250
271
  return finalize("c3_delegate", {"task_type": task_type, "backend": "claude"},
251
272
  output, "ok")
252
273
 
@@ -307,7 +328,7 @@ def _start_gemini_early(model: str, timeout: int = 45, idle_timeout: int = 15,
307
328
  cmd,
308
329
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
309
330
  stdin=subprocess.PIPE,
310
- text=True,
331
+ text=True, encoding="utf-8", errors="replace",
311
332
  cwd=cwd,
312
333
  **_popen_kwargs(),
313
334
  )
@@ -344,7 +365,7 @@ def _finish_gemini_early(proc, task: str, context: str,
344
365
  except (ValueError, OSError):
345
366
  pass
346
367
 
347
- t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts), daemon=True)
368
+ t_out = threading.Thread(target=_read_stream, args=(proc.stdout, stdout_parts, True), daemon=True)
348
369
  t_err = threading.Thread(target=_read_stream, args=(proc.stderr, stderr_parts, True), daemon=True)
349
370
  t_out.start()
350
371
  t_err.start()
@@ -448,7 +469,7 @@ def _run_gemini(task: str, context: str, model: str,
448
469
  cmd,
449
470
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
450
471
  stdin=subprocess.DEVNULL,
451
- text=True,
472
+ text=True, encoding="utf-8", errors="replace",
452
473
  cwd=cwd,
453
474
  **_popen_kwargs(),
454
475
  )
@@ -563,7 +584,7 @@ def _run_codex(task: str, context: str, model: str, sandbox: str,
563
584
  cmd,
564
585
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
565
586
  stdin=subprocess.DEVNULL,
566
- text=True,
587
+ text=True, encoding="utf-8", errors="replace",
567
588
  cwd=cwd,
568
589
  **_popen_kwargs(),
569
590
  )
@@ -592,25 +613,18 @@ def _run_codex_resume(follow_up: str, timeout: int = 120,
592
613
  """Resume last Codex session with a follow-up prompt."""
593
614
  cmd = ["codex", "exec", "--skip-git-repo-check", "resume", "--last"]
594
615
  try:
595
- import sys
596
616
  proc = subprocess.Popen(
597
617
  cmd,
598
618
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
599
619
  stdin=subprocess.PIPE,
600
- text=True,
620
+ text=True, encoding="utf-8", errors="replace",
601
621
  cwd=cwd,
622
+ **_popen_kwargs(),
602
623
  )
603
624
  try:
604
625
  stdout, stderr = proc.communicate(input=follow_up, timeout=timeout)
605
626
  except subprocess.TimeoutExpired:
606
- if sys.platform == "win32":
607
- subprocess.run(
608
- ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
609
- capture_output=True, stdin=subprocess.DEVNULL,
610
- )
611
- else:
612
- proc.kill()
613
- proc.wait(timeout=5)
627
+ _kill_proc_tree(proc)
614
628
  return f"[codex:timeout] Resume timed out after {timeout}s", False
615
629
 
616
630
  if proc.returncode != 0:
@@ -678,6 +692,51 @@ DELEGATE_TASKS = {
678
692
  _delegate_cache: dict[str, tuple[str, int]] = {}
679
693
  _delegate_metrics = {"total_calls": 0, "tokens_saved": 0}
680
694
 
695
+ # Per-backend runtime circuit breakers. Distinct from the install-status flags
696
+ # (_gemini_available etc., which only answer "is the CLI on PATH"): these track
697
+ # *runtime* health so a broken-but-installed backend (expired auth, repeated
698
+ # timeouts) stops re-spawning a 90-120s subprocess on every call. Keyed by
699
+ # backend name and intentionally process-global — backend health (auth, CLI
700
+ # version) is a property of the host, not of any single project.
701
+ _backend_breakers: dict[str, CircuitBreaker] = {}
702
+ _backend_breakers_lock = threading.Lock()
703
+
704
+
705
+ def _backend_breaker(name: str, dcfg: dict | None = None) -> CircuitBreaker:
706
+ """Return (creating on first use) the runtime circuit breaker for a backend."""
707
+ with _backend_breakers_lock:
708
+ breaker = _backend_breakers.get(name)
709
+ if breaker is None:
710
+ cfg = dcfg or {}
711
+ breaker = CircuitBreaker(
712
+ name,
713
+ failure_threshold=int(cfg.get("breaker_failure_threshold", 3) or 3),
714
+ cooldown_seconds=float(cfg.get("breaker_cooldown_seconds", 60) or 60),
715
+ )
716
+ _backend_breakers[name] = breaker
717
+ return breaker
718
+
719
+
720
+ def _notify_backend_degraded(svc, name: str, breaker: CircuitBreaker) -> None:
721
+ """Surface a backend trip via the NotificationStore (best-effort, never raises)."""
722
+ notifications = getattr(svc, "notifications", None)
723
+ if notifications is None:
724
+ return
725
+ try:
726
+ notifications.add(
727
+ agent="c3",
728
+ severity="warning",
729
+ title=f"Delegate backend degraded: {name}",
730
+ message=(
731
+ f"{name} failed {breaker.failure_threshold}x consecutively; c3_delegate "
732
+ f"will skip it for ~{int(breaker.cooldown_seconds)}s instead of re-spawning "
733
+ f"the CLI. Run '{name} --version' to diagnose."
734
+ ),
735
+ replace_if_unacked=True,
736
+ )
737
+ except Exception:
738
+ pass
739
+
681
740
 
682
741
  def get_delegate_metrics() -> dict:
683
742
  return dict(_delegate_metrics)
@@ -762,6 +821,13 @@ def _handle_codex_delegate(task: str, task_type: str, context: str,
762
821
  "[delegate:error] Codex CLI not available. Run 'codex --version' to diagnose.",
763
822
  "unavailable")
764
823
 
824
+ breaker = _backend_breaker("codex", dcfg)
825
+ if not breaker.allow():
826
+ return finalize("c3_delegate", {"task_type": task_type, "backend": "codex"},
827
+ "[delegate:degraded] Codex skipped after repeated failures; retrying in "
828
+ f"~{breaker.cooldown_remaining()}s. Run 'codex --version' to diagnose.",
829
+ "degraded")
830
+
765
831
  # Resolve model/sandbox/reasoning from config or defaults
766
832
  cdef = CODEX_MODELS.get(task_type, CODEX_MODELS.get("ask", {}))
767
833
  model = dcfg.get("codex_default_model") or cdef.get("model", "gpt-5.3-codex-spark")
@@ -804,10 +870,13 @@ def _handle_codex_delegate(task: str, task_type: str, context: str,
804
870
  elapsed = round(time.monotonic() - t0, 1)
805
871
 
806
872
  if not ok:
873
+ if breaker.record_failure():
874
+ _notify_backend_degraded(svc, "codex", breaker)
807
875
  return finalize("c3_delegate",
808
876
  {"task_type": task_type, "backend": "codex", "model": model, "elapsed": f"{elapsed}s"},
809
877
  output, "error")
810
878
 
879
+ breaker.record_success()
811
880
  _delegate_metrics["total_calls"] += 1
812
881
  _delegate_cache[ckey] = (output, count_tokens(output))
813
882
 
@@ -877,6 +946,13 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str,
877
946
  "[delegate:error] Gemini CLI not available. Run 'gemini --version' to diagnose.",
878
947
  "unavailable")
879
948
 
949
+ breaker = _backend_breaker("gemini", dcfg)
950
+ if not breaker.allow():
951
+ return finalize("c3_delegate", {"task_type": task_type, "backend": "gemini"},
952
+ "[delegate:degraded] Gemini skipped after repeated failures; retrying in "
953
+ f"~{breaker.cooldown_remaining()}s. Run 'gemini --version' to diagnose.",
954
+ "degraded")
955
+
880
956
  # Resolve model from config or defaults
881
957
  gdef = GEMINI_MODELS.get(task_type, GEMINI_MODELS.get("ask", {}))
882
958
  model = dcfg.get("gemini_default_model") or gdef.get("model", "gemini-2.5-flash")
@@ -916,10 +992,13 @@ def _handle_gemini_delegate(task: str, task_type: str, context: str,
916
992
  elapsed = round(time.monotonic() - t0, 1)
917
993
 
918
994
  if not ok:
995
+ if breaker.record_failure():
996
+ _notify_backend_degraded(svc, "gemini", breaker)
919
997
  return finalize("c3_delegate",
920
998
  {"task_type": task_type, "backend": "gemini", "model": model, "elapsed": f"{elapsed}s"},
921
999
  output, "error")
922
1000
 
1001
+ breaker.record_success()
923
1002
  _delegate_metrics["total_calls"] += 1
924
1003
  _delegate_cache[ckey] = (output, count_tokens(output))
925
1004
 
@@ -1065,9 +1144,11 @@ def handle_delegate(task: str, task_type: str, context: str, file_path: str,
1065
1144
  _gemini_avail = (_gemini_available is True) or (
1066
1145
  _gemini_available is None and task_type not in _light_tasks and _is_gemini_on_path()
1067
1146
  )
1068
- if task_type in heavy_codex and _codex_avail and _codex_available is not False:
1147
+ if (task_type in heavy_codex and _codex_avail and _codex_available is not False
1148
+ and _backend_breaker("codex", dcfg).allow()):
1069
1149
  backend = "codex"
1070
- elif task_type in heavy_gemini and _gemini_avail and _gemini_available is not False:
1150
+ elif (task_type in heavy_gemini and _gemini_avail and _gemini_available is not False
1151
+ and _backend_breaker("gemini", dcfg).allow()):
1071
1152
  backend = "gemini"
1072
1153
  else:
1073
1154
  backend = "ollama"
@@ -1166,7 +1247,10 @@ def handle_delegate(task: str, task_type: str, context: str, file_path: str,
1166
1247
  model=fallback, system=tdef["system"],
1167
1248
  temperature=tdef.get("temperature", 0.3),
1168
1249
  max_tokens=int(dcfg.get("max_tokens", 512) or 512),
1169
- )
1250
+ timeout=timeout_s)
1251
+ if retry_resp is None:
1252
+ # Timeout/failure on the fallback — not a valid empty answer.
1253
+ continue
1170
1254
  retry_conf = _estimate_confidence(task_type, retry_resp, count_tokens(retry_resp))
1171
1255
  if retry_conf != "low":
1172
1256
  resp = retry_resp