@scemoon/cdh 1.0.10-beta.1 → 1.0.10-beta.2

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 (788) hide show
  1. package/ai-dlc-skill/SKILL.md +106 -0
  2. package/ai-dlc-skill/architecture/project-structure.md +78 -0
  3. package/ai-dlc-skill/brownfield/README.md +36 -0
  4. package/ai-dlc-skill/brownfield/entry.md +45 -0
  5. package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
  6. package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
  7. package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
  8. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
  9. package/ai-dlc-skill/components/backend.md +47 -0
  10. package/ai-dlc-skill/components/desktop.md +63 -0
  11. package/ai-dlc-skill/components/mya.md +219 -0
  12. package/ai-dlc-skill/components/native.md +73 -0
  13. package/ai-dlc-skill/components/tta.md +235 -0
  14. package/ai-dlc-skill/components/web.md +77 -0
  15. package/ai-dlc-skill/components/wxa.md +219 -0
  16. package/ai-dlc-skill/contracts/README.md +25 -0
  17. package/ai-dlc-skill/core/adaptive-flow.md +107 -0
  18. package/ai-dlc-skill/core/security.md +29 -0
  19. package/ai-dlc-skill/core/task-registry.md +153 -0
  20. package/ai-dlc-skill/cross-tool/README.md +20 -0
  21. package/ai-dlc-skill/cross-tool/cline/export.sh +38 -0
  22. package/ai-dlc-skill/cross-tool/copilot/export.sh +41 -0
  23. package/ai-dlc-skill/cross-tool/cursor/export.sh +53 -0
  24. package/ai-dlc-skill/cross-tool/onecode/export.sh +27 -0
  25. package/ai-dlc-skill/cross-tool/opencode/export.sh +38 -0
  26. package/ai-dlc-skill/phases/debug/entry.md +48 -0
  27. package/ai-dlc-skill/phases/debug/lifecycle.md +284 -0
  28. package/ai-dlc-skill/phases/deliver/entry.md +35 -0
  29. package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
  30. package/ai-dlc-skill/phases/deliver/prompt.md +54 -0
  31. package/ai-dlc-skill/phases/deliver/rules.md +61 -0
  32. package/ai-dlc-skill/phases/plan/design_system/accessibility.md +268 -0
  33. package/ai-dlc-skill/phases/plan/design_system/atomic_design.md +341 -0
  34. package/ai-dlc-skill/phases/plan/design_system/component_spec.md +398 -0
  35. package/ai-dlc-skill/phases/plan/design_system/design_tokens.md +190 -0
  36. package/ai-dlc-skill/phases/plan/design_system/iconography.md +299 -0
  37. package/ai-dlc-skill/phases/plan/design_system/platform_ui/desktop.md +318 -0
  38. package/ai-dlc-skill/phases/plan/design_system/platform_ui/mya.md +354 -0
  39. package/ai-dlc-skill/phases/plan/design_system/platform_ui/native.md +328 -0
  40. package/ai-dlc-skill/phases/plan/design_system/platform_ui/tta.md +394 -0
  41. package/ai-dlc-skill/phases/plan/design_system/platform_ui/web.md +287 -0
  42. package/ai-dlc-skill/phases/plan/design_system/platform_ui/wxa.md +374 -0
  43. package/ai-dlc-skill/phases/plan/design_system/theme_system.md +318 -0
  44. package/ai-dlc-skill/phases/plan/entry.md +33 -0
  45. package/ai-dlc-skill/phases/plan/lifecycle.md +260 -0
  46. package/ai-dlc-skill/phases/plan/prompt.md +75 -0
  47. package/ai-dlc-skill/phases/plan/rules.md +60 -0
  48. package/ai-dlc-skill/phases/understand/entry.md +36 -0
  49. package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
  50. package/ai-dlc-skill/phases/understand/prompt.md +58 -0
  51. package/ai-dlc-skill/phases/understand/rules.md +49 -0
  52. package/ai-dlc-skill/phases/verify/config/base_url.md +65 -0
  53. package/ai-dlc-skill/phases/verify/entry.md +59 -0
  54. package/ai-dlc-skill/phases/verify/gates/browser_e2e.md +96 -0
  55. package/ai-dlc-skill/phases/verify/lifecycle.md +217 -0
  56. package/ai-dlc-skill/phases/verify/prompt.md +73 -0
  57. package/ai-dlc-skill/phases/verify/rules.md +91 -0
  58. package/ai-dlc-skill/phases/verify/runners/playwright_local.md +103 -0
  59. package/ai-dlc-skill/practices/bdd.md +119 -0
  60. package/ai-dlc-skill/practices/sdd.md +84 -0
  61. package/ai-dlc-skill/practices/tdd.md +128 -0
  62. package/ai-dlc-skill/providers/README.md +114 -0
  63. package/ai-dlc-skill/providers/aliyun/SKILL.md +204 -0
  64. package/ai-dlc-skill/providers/aliyun/api/rest-api.md +214 -0
  65. package/ai-dlc-skill/providers/aliyun/api/sdk.md +307 -0
  66. package/ai-dlc-skill/providers/aliyun/best-practices/database-design.md +363 -0
  67. package/ai-dlc-skill/providers/aliyun/best-practices/function-design.md +424 -0
  68. package/ai-dlc-skill/providers/aliyun/best-practices/troubleshooting.md +258 -0
  69. package/ai-dlc-skill/providers/aliyun/cli/database.md +263 -0
  70. package/ai-dlc-skill/providers/aliyun/cli/functions.md +263 -0
  71. package/ai-dlc-skill/providers/aliyun/cli/hosting.md +226 -0
  72. package/ai-dlc-skill/providers/aliyun/cli/setup.md +235 -0
  73. package/ai-dlc-skill/providers/aliyun/cli/storage.md +315 -0
  74. package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
  75. package/ai-dlc-skill/providers/aliyun/integration/ci-cd.md +288 -0
  76. package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
  77. package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
  78. package/ai-dlc-skill/providers/aliyun/security/authentication.md +254 -0
  79. package/ai-dlc-skill/providers/aliyun/services/cdn-hosting.md +220 -0
  80. package/ai-dlc-skill/providers/aliyun/services/database.md +319 -0
  81. package/ai-dlc-skill/providers/aliyun/services/function-compute.md +273 -0
  82. package/ai-dlc-skill/providers/aliyun/services/overview.md +163 -0
  83. package/ai-dlc-skill/providers/aliyun/services/serverless-app-engine.md +266 -0
  84. package/ai-dlc-skill/providers/aliyun/services/storage.md +269 -0
  85. package/ai-dlc-skill/providers/tcb/SKILL.md +208 -0
  86. package/ai-dlc-skill/providers/tcb/api/rest-api.md +338 -0
  87. package/ai-dlc-skill/providers/tcb/api/sdk.md +288 -0
  88. package/ai-dlc-skill/providers/tcb/best-practices/database-design.md +452 -0
  89. package/ai-dlc-skill/providers/tcb/best-practices/function-design.md +502 -0
  90. package/ai-dlc-skill/providers/tcb/best-practices/troubleshooting.md +462 -0
  91. package/ai-dlc-skill/providers/tcb/cli/cls.md +133 -0
  92. package/ai-dlc-skill/providers/tcb/cli/database.md +230 -0
  93. package/ai-dlc-skill/providers/tcb/cli/env.md +207 -0
  94. package/ai-dlc-skill/providers/tcb/cli/functions.md +276 -0
  95. package/ai-dlc-skill/providers/tcb/cli/hosting.md +219 -0
  96. package/ai-dlc-skill/providers/tcb/cli/setup.md +218 -0
  97. package/ai-dlc-skill/providers/tcb/cli/storage.md +233 -0
  98. package/ai-dlc-skill/providers/tcb/deployment.yaml +111 -0
  99. package/ai-dlc-skill/providers/tcb/integration/ci-cd.md +309 -0
  100. package/ai-dlc-skill/providers/tcb/integration/mcp-server.md +371 -0
  101. package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
  102. package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
  103. package/ai-dlc-skill/providers/tcb/security/authentication.md +241 -0
  104. package/ai-dlc-skill/providers/tcb/services/cloud-database.md +299 -0
  105. package/ai-dlc-skill/providers/tcb/services/cloud-functions.md +265 -0
  106. package/ai-dlc-skill/providers/tcb/services/cloud-hosting.md +264 -0
  107. package/ai-dlc-skill/providers/tcb/services/cloud-run.md +285 -0
  108. package/ai-dlc-skill/providers/tcb/services/cloud-storage.md +245 -0
  109. package/ai-dlc-skill/providers/tcb/services/overview.md +174 -0
  110. package/ai-dlc-skill/pybdd/README.md +41 -0
  111. package/ai-dlc-skill/pybdd/conftest.py +17 -0
  112. package/ai-dlc-skill/pybdd/example/auth/login.feature +32 -0
  113. package/ai-dlc-skill/pybdd/pytest.ini +5 -0
  114. package/ai-dlc-skill/pybdd/steps/.gitkeep +1 -0
  115. package/ai-dlc-skill/pybdd/steps/test_example_steps.py +65 -0
  116. package/ai-dlc-skill/requirements.md +140 -0
  117. package/ai-dlc-skill/skill.yaml +135 -0
  118. package/ai-dlc-skill/templates/brownfield/context.md +78 -0
  119. package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
  120. package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
  121. package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
  122. package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
  123. package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
  124. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
  125. package/ai-dlc-skill/templates/plan/design.md +49 -0
  126. package/ai-dlc-skill/templates/plan/task-list.md +32 -0
  127. package/ai-dlc-skill/templates/project/README.md +126 -0
  128. package/ai-dlc-skill/templates/project/template.md +119 -0
  129. package/ai-dlc-skill/templates/understand/feature.feature +30 -0
  130. package/ai-dlc-skill/templates/understand/intent.md +29 -0
  131. package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
  132. package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
  133. package/ai-dlc-skill/walkthrough/collect.sh +50 -0
  134. package/ai-dlc-skill/walkthrough/template.md +54 -0
  135. package/cdh/__init__.py +0 -0
  136. package/cdh/__main__.py +4 -0
  137. package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
  138. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  139. package/cdh/__pycache__/cli_logging.cpython-314.pyc +0 -0
  140. package/cdh/__pycache__/config.cpython-314.pyc +0 -0
  141. package/cdh/__pycache__/project_loader.cpython-314.pyc +0 -0
  142. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  143. package/cdh/__pycache__/session_store.cpython-314.pyc +0 -0
  144. package/cdh/cdh_session_aggregator.py +258 -0
  145. package/cdh/cli.py +3000 -0
  146. package/cdh/cli_logging.py +45 -0
  147. package/cdh/config.py +28 -0
  148. package/cdh/event_loop/__init__.py +7 -0
  149. package/cdh/event_loop/bus.py +123 -0
  150. package/cdh/event_loop/events.py +25 -0
  151. package/cdh/event_loop/runner.py +28 -0
  152. package/cdh/event_loop/scheduler.py +158 -0
  153. package/cdh/generators/__init__.py +35 -0
  154. package/cdh/generators/_runtime.py +176 -0
  155. package/cdh/generators/ada/MANIFEST.toml +14 -0
  156. package/cdh/generators/ada/template.ads.tmpl +7 -0
  157. package/cdh/generators/bash/MANIFEST.toml +14 -0
  158. package/cdh/generators/bash/template.sh.tmpl +16 -0
  159. package/cdh/generators/builtin_index.json +590 -0
  160. package/cdh/generators/c/MANIFEST.toml +14 -0
  161. package/cdh/generators/c/template.h.tmpl +20 -0
  162. package/cdh/generators/cli.py +379 -0
  163. package/cdh/generators/clojure/MANIFEST.toml +14 -0
  164. package/cdh/generators/clojure/template.clj.tmpl +13 -0
  165. package/cdh/generators/cobol/MANIFEST.toml +14 -0
  166. package/cdh/generators/cobol/template.cbl.tmpl +14 -0
  167. package/cdh/generators/cpp/MANIFEST.toml +14 -0
  168. package/cdh/generators/cpp/template.hpp.tmpl +32 -0
  169. package/cdh/generators/crystal/MANIFEST.toml +14 -0
  170. package/cdh/generators/crystal/template.cr.tmpl +11 -0
  171. package/cdh/generators/csharp/MANIFEST.toml +23 -0
  172. package/cdh/generators/csharp/template.cs.tmpl +14 -0
  173. package/cdh/generators/cython/MANIFEST.toml +14 -0
  174. package/cdh/generators/cython/template.pyx.tmpl +17 -0
  175. package/cdh/generators/d/MANIFEST.toml +14 -0
  176. package/cdh/generators/d/template.d.tmpl +24 -0
  177. package/cdh/generators/dart/MANIFEST.toml +17 -0
  178. package/cdh/generators/dart/template.dart.tmpl +27 -0
  179. package/cdh/generators/elixir/MANIFEST.toml +17 -0
  180. package/cdh/generators/elixir/template.ex.tmpl +27 -0
  181. package/cdh/generators/elm/MANIFEST.toml +14 -0
  182. package/cdh/generators/elm/template.elm.tmpl +20 -0
  183. package/cdh/generators/erlang/MANIFEST.toml +14 -0
  184. package/cdh/generators/erlang/template.erl.tmpl +18 -0
  185. package/cdh/generators/fortran/MANIFEST.toml +14 -0
  186. package/cdh/generators/fortran/template.f90.tmpl +24 -0
  187. package/cdh/generators/fsharp/MANIFEST.toml +14 -0
  188. package/cdh/generators/fsharp/template.fs.tmpl +17 -0
  189. package/cdh/generators/go/MANIFEST.toml +18 -0
  190. package/cdh/generators/go/imports.go.tmpl +3 -0
  191. package/cdh/generators/go/template.go.tmpl +9 -0
  192. package/cdh/generators/graphql-schema/MANIFEST.toml +18 -0
  193. package/cdh/generators/graphql-schema/template.graphqls.tmpl +12 -0
  194. package/cdh/generators/groovy/MANIFEST.toml +14 -0
  195. package/cdh/generators/groovy/template.groovy.tmpl +28 -0
  196. package/cdh/generators/hack/MANIFEST.toml +14 -0
  197. package/cdh/generators/hack/template.hh.tmpl +24 -0
  198. package/cdh/generators/haskell/MANIFEST.toml +14 -0
  199. package/cdh/generators/haskell/template.hs.tmpl +20 -0
  200. package/cdh/generators/haxe/MANIFEST.toml +14 -0
  201. package/cdh/generators/haxe/template.hx.tmpl +20 -0
  202. package/cdh/generators/idris/MANIFEST.toml +14 -0
  203. package/cdh/generators/idris/template.idr.tmpl +22 -0
  204. package/cdh/generators/java/MANIFEST.toml +18 -0
  205. package/cdh/generators/java/template.java.tmpl +13 -0
  206. package/cdh/generators/julia/MANIFEST.toml +14 -0
  207. package/cdh/generators/julia/template.jl.tmpl +27 -0
  208. package/cdh/generators/kotlin/MANIFEST.toml +18 -0
  209. package/cdh/generators/kotlin/template.kt.tmpl +12 -0
  210. package/cdh/generators/lua/MANIFEST.toml +14 -0
  211. package/cdh/generators/lua/template.lua.tmpl +27 -0
  212. package/cdh/generators/matlab/MANIFEST.toml +14 -0
  213. package/cdh/generators/matlab/template.m.tmpl +26 -0
  214. package/cdh/generators/nim/MANIFEST.toml +14 -0
  215. package/cdh/generators/nim/template.nim.tmpl +12 -0
  216. package/cdh/generators/nix/MANIFEST.toml +14 -0
  217. package/cdh/generators/nix/template.nix.tmpl +8 -0
  218. package/cdh/generators/objective-c/MANIFEST.toml +14 -0
  219. package/cdh/generators/objective-c/template.m.tmpl +31 -0
  220. package/cdh/generators/ocaml/MANIFEST.toml +14 -0
  221. package/cdh/generators/ocaml/template.ml.tmpl +13 -0
  222. package/cdh/generators/pascal/MANIFEST.toml +14 -0
  223. package/cdh/generators/pascal/template.pas.tmpl +5 -0
  224. package/cdh/generators/perl/MANIFEST.toml +14 -0
  225. package/cdh/generators/perl/template.pm.tmpl +27 -0
  226. package/cdh/generators/php/MANIFEST.toml +17 -0
  227. package/cdh/generators/php/template.php.tmpl +31 -0
  228. package/cdh/generators/powershell/MANIFEST.toml +14 -0
  229. package/cdh/generators/powershell/template.ps1.tmpl +20 -0
  230. package/cdh/generators/protobuf/MANIFEST.toml +18 -0
  231. package/cdh/generators/protobuf/template.proto.tmpl +13 -0
  232. package/cdh/generators/python/MANIFEST.toml +18 -0
  233. package/cdh/generators/python/template.py.tmpl +12 -0
  234. package/cdh/generators/r/MANIFEST.toml +14 -0
  235. package/cdh/generators/r/template.R.tmpl +29 -0
  236. package/cdh/generators/racket/MANIFEST.toml +14 -0
  237. package/cdh/generators/racket/template.rkt.tmpl +15 -0
  238. package/cdh/generators/registry.py +211 -0
  239. package/cdh/generators/ruby/MANIFEST.toml +17 -0
  240. package/cdh/generators/ruby/template.rb.tmpl +27 -0
  241. package/cdh/generators/rust/MANIFEST.toml +18 -0
  242. package/cdh/generators/rust/template.rs.tmpl +16 -0
  243. package/cdh/generators/scala/MANIFEST.toml +20 -0
  244. package/cdh/generators/scala/template.scala.tmpl +13 -0
  245. package/cdh/generators/scheme/MANIFEST.toml +14 -0
  246. package/cdh/generators/scheme/template.scm.tmpl +16 -0
  247. package/cdh/generators/solidity/MANIFEST.toml +14 -0
  248. package/cdh/generators/solidity/template.sol.tmpl +24 -0
  249. package/cdh/generators/swift/MANIFEST.toml +18 -0
  250. package/cdh/generators/swift/template.swift.tmpl +10 -0
  251. package/cdh/generators/typescript/MANIFEST.toml +18 -0
  252. package/cdh/generators/typescript/template.ts.tmpl +7 -0
  253. package/cdh/generators/version.py +214 -0
  254. package/cdh/generators/zig/MANIFEST.toml +14 -0
  255. package/cdh/generators/zig/template.zig.tmpl +20 -0
  256. package/cdh/logging.py +314 -0
  257. package/cdh/optimizer/__init__.py +7 -0
  258. package/cdh/optimizer/loop.py +201 -0
  259. package/cdh/optimizer/mutation.py +85 -0
  260. package/cdh/optimizer/reward.py +51 -0
  261. package/cdh/optimizer/tracker.py +118 -0
  262. package/cdh/project_loader.py +1022 -0
  263. package/cdh/rules/patterns.yaml +132 -0
  264. package/cdh/rules/spectral.yaml +301 -0
  265. package/cdh/scaffold.py +1436 -0
  266. package/cdh/session_store.py +221 -0
  267. package/cdh/state_schema.py +127 -0
  268. package/cdh/tools/__init__.py +57 -0
  269. package/cdh/tools/cls_search.py +406 -0
  270. package/cdh/tools/contract_diff.py +644 -0
  271. package/cdh/tools/deploy_stack.py +590 -0
  272. package/cdh/tools/generate_shared.py +300 -0
  273. package/cdh/tools/iac_gen.py +224 -0
  274. package/cdh/tools/otel_export.py +125 -0
  275. package/cdh/tools/pattern_detect.py +451 -0
  276. package/cdh/tools/retrofit_labels.py +505 -0
  277. package/cdh/trace/__init__.py +101 -0
  278. package/cdh/trace/__pycache__/__init__.cpython-314.pyc +0 -0
  279. package/cdh/trace/dashboard/__init__.py +61 -0
  280. package/cdh/trace/dashboard/api.py +1032 -0
  281. package/cdh/trace/dashboard/app.html +1321 -0
  282. package/cdh/trace/dashboard/server.py +310 -0
  283. package/cdh/trace/otel_exporter.py +462 -0
  284. package/cdh/trace/otel_tracer.py +281 -0
  285. package/cdh/tui.py +1084 -0
  286. package/cdh/validators/__init__.py +16 -0
  287. package/cdh/validators/__pycache__/__init__.cpython-314.pyc +0 -0
  288. package/cdh/validators/__pycache__/bdd.cpython-314.pyc +0 -0
  289. package/cdh/validators/__pycache__/dag.cpython-314.pyc +0 -0
  290. package/cdh/validators/__pycache__/ears.cpython-314.pyc +0 -0
  291. package/cdh/validators/__pycache__/fr_namespace.cpython-314.pyc +0 -0
  292. package/cdh/validators/bdd.py +144 -0
  293. package/cdh/validators/dag.py +179 -0
  294. package/cdh/validators/ears.py +452 -0
  295. package/cdh/validators/fr_namespace.py +143 -0
  296. package/cdh/validators/spectral.py +475 -0
  297. package/cdh/verification/__init__.py +3 -0
  298. package/cdh/verification/aggregation.py +53 -0
  299. package/cdh/verification/gates/__init__.py +5 -0
  300. package/cdh/verification/gates/base.py +18 -0
  301. package/cdh/verification/gates/lint_gate.py +37 -0
  302. package/cdh/verification/gates/test_gate.py +41 -0
  303. package/cdh/verification/loop.py +93 -0
  304. package/cdh/verification/policy.py +24 -0
  305. package/onecode/__init__.py +1 -0
  306. package/onecode/__main__.py +4 -0
  307. package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
  308. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  309. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  310. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  311. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  312. package/onecode/__pycache__/migrate.cpython-314.pyc +0 -0
  313. package/onecode/agent/__init__.py +0 -0
  314. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  315. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  316. package/onecode/agent/__pycache__/checkpoint.cpython-314.pyc +0 -0
  317. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  318. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  319. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  320. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  321. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  322. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  323. package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
  324. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  325. package/onecode/agent/__pycache__/turn_record.cpython-314.pyc +0 -0
  326. package/onecode/agent/agents/__init__.py +29 -0
  327. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  328. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  329. package/onecode/agent/agents/prompts/build-agent.md +50 -0
  330. package/onecode/agent/agents/prompts/compaction-instructions.md +12 -0
  331. package/onecode/agent/agents/prompts/explore-agent.md +42 -0
  332. package/onecode/agent/agents/prompts/plan-agent.md +44 -0
  333. package/onecode/agent/agents/prompts/plan-gate-hard.md +4 -0
  334. package/onecode/agent/agents/prompts/plan-gate-soft.md +3 -0
  335. package/onecode/agent/agents/prompts/solo-agent.md +57 -0
  336. package/onecode/agent/agents/prompts/subagent-constraints.md +10 -0
  337. package/onecode/agent/agents/prompts/summary-instructions.md +7 -0
  338. package/onecode/agent/agents/prompts/title-instructions.md +3 -0
  339. package/onecode/agent/agents/prompts/tool-descriptions.md +129 -0
  340. package/onecode/agent/agents/types.py +786 -0
  341. package/onecode/agent/attachments.py +112 -0
  342. package/onecode/agent/cdh_loader.py +3 -0
  343. package/onecode/agent/checkpoint.py +285 -0
  344. package/onecode/agent/context.py +465 -0
  345. package/onecode/agent/engine.py +3567 -0
  346. package/onecode/agent/event_bridge.py +75 -0
  347. package/onecode/agent/hooks.py +86 -0
  348. package/onecode/agent/onecode_agent_acp.py +2993 -0
  349. package/onecode/agent/permissions.py +150 -0
  350. package/onecode/agent/permissions_store.py +73 -0
  351. package/onecode/agent/project_doc.py +19 -0
  352. package/onecode/agent/question_detect.py +230 -0
  353. package/onecode/agent/session.py +3 -0
  354. package/onecode/agent/snapshot.py +144 -0
  355. package/onecode/agent/tools/__init__.py +17 -0
  356. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  357. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  358. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  359. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  360. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  361. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  362. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  363. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  364. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  365. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  366. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  367. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  368. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  369. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  370. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  371. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  372. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  373. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  374. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  375. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  376. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  377. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  378. package/onecode/agent/tools/agent_tools.py +128 -0
  379. package/onecode/agent/tools/apply_patch_tool.py +138 -0
  380. package/onecode/agent/tools/bash_tool.py +121 -0
  381. package/onecode/agent/tools/communication_tools.py +154 -0
  382. package/onecode/agent/tools/config_tool.py +130 -0
  383. package/onecode/agent/tools/cron_tools.py +178 -0
  384. package/onecode/agent/tools/errors.py +17 -0
  385. package/onecode/agent/tools/file_ops.py +221 -0
  386. package/onecode/agent/tools/file_tools.py +225 -0
  387. package/onecode/agent/tools/git_tools.py +88 -0
  388. package/onecode/agent/tools/lsp_tools.py +404 -0
  389. package/onecode/agent/tools/mcp_tools.py +211 -0
  390. package/onecode/agent/tools/permission_handler.py +46 -0
  391. package/onecode/agent/tools/permissions.py +77 -0
  392. package/onecode/agent/tools/protocol.py +20 -0
  393. package/onecode/agent/tools/registry.py +210 -0
  394. package/onecode/agent/tools/sandbox.py +460 -0
  395. package/onecode/agent/tools/schema_validation.py +100 -0
  396. package/onecode/agent/tools/skill_tools.py +52 -0
  397. package/onecode/agent/tools/todo_tools.py +208 -0
  398. package/onecode/agent/tools/web_tools.py +530 -0
  399. package/onecode/agent/turn_record.py +28 -0
  400. package/onecode/builtin_skills/agent-browser/SKILL.md +212 -0
  401. package/onecode/builtin_skills/agent-browser/skill.yaml +8 -0
  402. package/onecode/builtin_skills/cloudbase/SKILL.md +162 -0
  403. package/onecode/builtin_skills/cloudbase/skill.yaml +7 -0
  404. package/onecode/builtin_skills/git/SKILL.md +64 -0
  405. package/onecode/builtin_skills/git/skill.yaml +7 -0
  406. package/onecode/builtin_skills/shell/SKILL.md +58 -0
  407. package/onecode/builtin_skills/shell/skill.yaml +8 -0
  408. package/onecode/builtin_skills/skill-creator/SKILL.md +106 -0
  409. package/onecode/builtin_skills/skill-creator/skill.yaml +8 -0
  410. package/onecode/cli.py +1325 -0
  411. package/onecode/codebase/__init__.py +69 -0
  412. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  413. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  414. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  415. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  416. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  417. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  418. package/onecode/codebase/chunker.py +125 -0
  419. package/onecode/codebase/indexer.py +154 -0
  420. package/onecode/codebase/retriever.py +240 -0
  421. package/onecode/codebase/storage.py +153 -0
  422. package/onecode/codebase/tools.py +59 -0
  423. package/onecode/commands.py +918 -0
  424. package/onecode/config.py +539 -0
  425. package/onecode/config_screen.py +486 -0
  426. package/onecode/cron/__init__.py +3 -0
  427. package/onecode/lsp/__init__.py +3 -0
  428. package/onecode/mcp/__init__.py +11 -0
  429. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  430. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  431. package/onecode/mcp/__pycache__/cloudbase.cpython-314.pyc +0 -0
  432. package/onecode/mcp/__pycache__/config.cpython-314.pyc +0 -0
  433. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  434. package/onecode/mcp/__pycache__/oauth.cpython-314.pyc +0 -0
  435. package/onecode/mcp/client.py +305 -0
  436. package/onecode/mcp/cloudbase.py +188 -0
  437. package/onecode/mcp/config.py +363 -0
  438. package/onecode/mcp/manager.py +603 -0
  439. package/onecode/mcp/oauth.py +199 -0
  440. package/onecode/memory/__init__.py +106 -0
  441. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  442. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  443. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  444. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  445. package/onecode/memory/backend.py +179 -0
  446. package/onecode/memory/pyramid.py +145 -0
  447. package/onecode/memory/recall.py +322 -0
  448. package/onecode/migrate.py +122 -0
  449. package/onecode/models/__init__.py +4 -0
  450. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  451. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  452. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  453. package/onecode/models/__pycache__/ollama_manager.cpython-314.pyc +0 -0
  454. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  455. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  456. package/onecode/models/errors.py +104 -0
  457. package/onecode/models/messages.py +750 -0
  458. package/onecode/models/ollama_manager.py +167 -0
  459. package/onecode/models/provider.py +730 -0
  460. package/onecode/models/providers/__init__.py +17 -0
  461. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  462. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  463. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  464. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  465. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  466. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  467. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  468. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  469. package/onecode/models/providers/anthropic_provider.py +286 -0
  470. package/onecode/models/providers/deepseek_provider.py +221 -0
  471. package/onecode/models/providers/glm_provider.py +219 -0
  472. package/onecode/models/providers/minimax_provider.py +182 -0
  473. package/onecode/models/providers/minimaxi_provider.py +426 -0
  474. package/onecode/models/providers/ollama_provider.py +183 -0
  475. package/onecode/models/providers/openai_provider.py +234 -0
  476. package/onecode/models/registry.py +349 -0
  477. package/onecode/server/__init__.py +3 -0
  478. package/onecode/server/app.py +251 -0
  479. package/onecode/skills/__init__.py +11 -0
  480. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  481. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  482. package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
  483. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  484. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  485. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  486. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  487. package/onecode/skills/argument_substitution.py +38 -0
  488. package/onecode/skills/bootstrap.py +37 -0
  489. package/onecode/skills/create.py +56 -0
  490. package/onecode/skills/frontmatter.py +33 -0
  491. package/onecode/skills/loader.py +215 -0
  492. package/onecode/skills/manager.py +75 -0
  493. package/onecode/skills/model.py +43 -0
  494. package/onecode/storage/__init__.py +0 -0
  495. package/onecode/storage/activity.py +63 -0
  496. package/onecode/storage/project.py +54 -0
  497. package/onecode/storage/session.py +112 -0
  498. package/onecode/tasks/__init__.py +26 -0
  499. package/onecode/tasks/manager.py +232 -0
  500. package/onecode/tasks/models.py +240 -0
  501. package/onecode/trace/__init__.py +0 -0
  502. package/onecode/trace/tracer.py +173 -0
  503. package/onecode/utils/__init__.py +0 -0
  504. package/onecode/utils/helpers.py +20 -0
  505. package/onecode/verification/__init__.py +5 -0
  506. package/onecode/verification/__pycache__/__init__.cpython-314.pyc +0 -0
  507. package/onecode/verification/__pycache__/aggregation.cpython-314.pyc +0 -0
  508. package/onecode/verification/__pycache__/loop.cpython-314.pyc +0 -0
  509. package/onecode/verification/__pycache__/policy.cpython-314.pyc +0 -0
  510. package/onecode/verification/aggregation.py +53 -0
  511. package/onecode/verification/gates/__init__.py +6 -0
  512. package/onecode/verification/gates/__pycache__/__init__.cpython-314.pyc +0 -0
  513. package/onecode/verification/gates/__pycache__/base.cpython-314.pyc +0 -0
  514. package/onecode/verification/gates/__pycache__/lint_gate.cpython-314.pyc +0 -0
  515. package/onecode/verification/gates/__pycache__/test_gate.cpython-314.pyc +0 -0
  516. package/onecode/verification/gates/__pycache__/type_gate.cpython-314.pyc +0 -0
  517. package/onecode/verification/gates/base.py +20 -0
  518. package/onecode/verification/gates/lint_gate.py +45 -0
  519. package/onecode/verification/gates/test_gate.py +62 -0
  520. package/onecode/verification/gates/type_gate.py +46 -0
  521. package/onecode/verification/loop.py +95 -0
  522. package/onecode/verification/policy.py +29 -0
  523. package/package.json +2 -9
  524. package/pyproject.toml +84 -0
  525. package/tui/__init__.py +46 -0
  526. package/tui/__main__.py +4 -0
  527. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  528. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  529. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  530. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  531. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  532. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  533. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  534. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  535. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  536. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  537. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  538. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  539. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  540. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  541. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  542. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  543. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  544. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  545. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  546. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  547. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  548. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  549. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  550. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  551. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  552. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  553. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  554. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  555. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  556. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  557. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  558. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  559. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  560. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  561. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  562. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  563. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  564. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  565. package/tui/_loop.py +86 -0
  566. package/tui/_path_fuzzy_search.py +48 -0
  567. package/tui/_path_match.py +141 -0
  568. package/tui/about.py +95 -0
  569. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  570. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  571. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  572. package/tui/acp/__pycache__/event_tap.cpython-314.pyc +0 -0
  573. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  574. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  575. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  576. package/tui/acp/agent.py +1212 -0
  577. package/tui/acp/api.py +79 -0
  578. package/tui/acp/encode_tool_call_id.py +12 -0
  579. package/tui/acp/event_tap.py +107 -0
  580. package/tui/acp/messages.py +222 -0
  581. package/tui/acp/prompt.py +53 -0
  582. package/tui/acp/protocol.py +551 -0
  583. package/tui/agent.py +70 -0
  584. package/tui/agent_schema.py +76 -0
  585. package/tui/agents.py +46 -0
  586. package/tui/ansi/__init__.py +1 -0
  587. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  588. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  589. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  590. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  591. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  592. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  593. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  594. package/tui/ansi/_ansi.py +1689 -0
  595. package/tui/ansi/_ansi_colors.py +264 -0
  596. package/tui/ansi/_control_codes.py +37 -0
  597. package/tui/ansi/_keys.py +251 -0
  598. package/tui/ansi/_sgr_styles.py +64 -0
  599. package/tui/ansi/_stream_parser.py +418 -0
  600. package/tui/answer.py +12 -0
  601. package/tui/app.py +1204 -0
  602. package/tui/atomic.py +37 -0
  603. package/tui/cli.py +208 -0
  604. package/tui/code_analyze.py +27 -0
  605. package/tui/complete.py +38 -0
  606. package/tui/constants.py +61 -0
  607. package/tui/conversation_markdown.py +30 -0
  608. package/tui/danger.py +372 -0
  609. package/tui/data/agents/ampcode.com.toml +52 -0
  610. package/tui/data/agents/augmentcode.com.toml +41 -0
  611. package/tui/data/agents/cdh.cloud-dev-harness.toml +23 -0
  612. package/tui/data/agents/claude.com.toml +42 -0
  613. package/tui/data/agents/copilot.github.com.toml +29 -0
  614. package/tui/data/agents/crowai.dev.toml +36 -0
  615. package/tui/data/agents/cursor.com.toml +38 -0
  616. package/tui/data/agents/deepagents.langchain.com +34 -0
  617. package/tui/data/agents/docker.com.toml +60 -0
  618. package/tui/data/agents/geminicli.com.toml +28 -0
  619. package/tui/data/agents/goose.ai.toml +52 -0
  620. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  621. package/tui/data/agents/kimi.com.toml +35 -0
  622. package/tui/data/agents/kiro.dev.toml +43 -0
  623. package/tui/data/agents/onecode.dev.toml +22 -0
  624. package/tui/data/agents/openai.com.toml +53 -0
  625. package/tui/data/agents/openclaw.ai.toml +37 -0
  626. package/tui/data/agents/opencode.ai.toml +39 -0
  627. package/tui/data/agents/openhands.dev.toml +46 -0
  628. package/tui/data/agents/stakpak.dev.toml +62 -0
  629. package/tui/data/agents/vibe.mistral.ai.toml +31 -0
  630. package/tui/data/agents/vtcode.dev.toml +63 -0
  631. package/tui/data/images/frog.png +0 -0
  632. package/tui/data/sounds/question.wav +0 -0
  633. package/tui/data/sounds/turn-over.wav +0 -0
  634. package/tui/db.py +213 -0
  635. package/tui/dec.py +332 -0
  636. package/tui/diff_utils.py +131 -0
  637. package/tui/directory.py +234 -0
  638. package/tui/directory_suggester.py +93 -0
  639. package/tui/directory_watcher.py +231 -0
  640. package/tui/format_path.py +25 -0
  641. package/tui/fuzzy.py +140 -0
  642. package/tui/fuzzy_index.py +275 -0
  643. package/tui/gist.py +33 -0
  644. package/tui/history.py +138 -0
  645. package/tui/jsonrpc.py +599 -0
  646. package/tui/menus.py +14 -0
  647. package/tui/message_log.py +69 -0
  648. package/tui/messages.py +144 -0
  649. package/tui/option_content.py +51 -0
  650. package/tui/os.py +0 -0
  651. package/tui/path_complete.py +217 -0
  652. package/tui/path_filter.py +124 -0
  653. package/tui/paths.py +87 -0
  654. package/tui/pill.py +23 -0
  655. package/tui/platform_commands.py +52 -0
  656. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  657. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  658. package/tui/prompt/extract.py +19 -0
  659. package/tui/prompt/resource.py +68 -0
  660. package/tui/protocol.py +28 -0
  661. package/tui/screens/__init__.py +0 -0
  662. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  663. package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
  664. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  665. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  666. package/tui/screens/action_modal.py +94 -0
  667. package/tui/screens/agent_modal.py +175 -0
  668. package/tui/screens/command_edit_modal.py +74 -0
  669. package/tui/screens/diff_explorer_screen.py +419 -0
  670. package/tui/screens/diff_screen.py +84 -0
  671. package/tui/screens/ide_screen.py +461 -0
  672. package/tui/screens/main.py +397 -0
  673. package/tui/screens/main.tcss +662 -0
  674. package/tui/screens/permissions.py +404 -0
  675. package/tui/screens/permissions.tcss +72 -0
  676. package/tui/screens/projects.tcss +84 -0
  677. package/tui/screens/projects_app.py +341 -0
  678. package/tui/screens/projects_screen.py +277 -0
  679. package/tui/screens/session_resume_modal.py +172 -0
  680. package/tui/screens/session_resume_modal.tcss +35 -0
  681. package/tui/screens/sessions.py +168 -0
  682. package/tui/screens/sessions.tcss +119 -0
  683. package/tui/screens/settings.py +293 -0
  684. package/tui/screens/settings.tcss +113 -0
  685. package/tui/screens/store.py +502 -0
  686. package/tui/screens/store.tcss +274 -0
  687. package/tui/screens/tool_content_screen.py +59 -0
  688. package/tui/screens/trace.py +317 -0
  689. package/tui/session_tracker.py +123 -0
  690. package/tui/settings.py +353 -0
  691. package/tui/settings_schema.py +467 -0
  692. package/tui/shell.py +304 -0
  693. package/tui/shell_read.py +42 -0
  694. package/tui/slash_command.py +34 -0
  695. package/tui/tui.tcss +246 -0
  696. package/tui/twitter.py +38 -0
  697. package/tui/version.py +80 -0
  698. package/tui/visuals/__init__.py +0 -0
  699. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  700. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  701. package/tui/visuals/columns.py +272 -0
  702. package/tui/widgets/__init__.py +0 -0
  703. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  704. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  705. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  706. package/tui/widgets/__pycache__/aidlc.cpython-314.pyc +0 -0
  707. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  708. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  709. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  710. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  711. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  712. package/tui/widgets/__pycache__/edit_field.cpython-314.pyc +0 -0
  713. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  714. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  715. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  716. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  717. package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
  718. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  719. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  720. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  721. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  722. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  723. package/tui/widgets/__pycache__/pending_prompts.cpython-314.pyc +0 -0
  724. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  725. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  726. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  727. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  728. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  729. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  730. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  731. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  732. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  733. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  734. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  735. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  736. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  737. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  738. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  739. package/tui/widgets/acp_content.py +70 -0
  740. package/tui/widgets/agent_response.py +76 -0
  741. package/tui/widgets/agent_thought.py +167 -0
  742. package/tui/widgets/aidlc.py +334 -0
  743. package/tui/widgets/ask_user.py +604 -0
  744. package/tui/widgets/command_pane.py +223 -0
  745. package/tui/widgets/condensed_path.py +115 -0
  746. package/tui/widgets/context_stats.py +66 -0
  747. package/tui/widgets/conversation.py +3234 -0
  748. package/tui/widgets/danger_warning.py +65 -0
  749. package/tui/widgets/diff_view.py +69 -0
  750. package/tui/widgets/directory_input.py +8 -0
  751. package/tui/widgets/edit_field.py +206 -0
  752. package/tui/widgets/flash.py +81 -0
  753. package/tui/widgets/future_text.py +126 -0
  754. package/tui/widgets/grid_select.py +234 -0
  755. package/tui/widgets/highlighted_textarea.py +183 -0
  756. package/tui/widgets/load_more.py +24 -0
  757. package/tui/widgets/mandelbrot.py +294 -0
  758. package/tui/widgets/markdown_note.py +13 -0
  759. package/tui/widgets/menu.py +147 -0
  760. package/tui/widgets/model_status.py +230 -0
  761. package/tui/widgets/modified_files.py +315 -0
  762. package/tui/widgets/non_selectable_label.py +5 -0
  763. package/tui/widgets/note.py +18 -0
  764. package/tui/widgets/path_search.py +492 -0
  765. package/tui/widgets/pending_prompts.py +36 -0
  766. package/tui/widgets/plan.py +285 -0
  767. package/tui/widgets/project_directory_tree.py +102 -0
  768. package/tui/widgets/project_grid_select.py +68 -0
  769. package/tui/widgets/project_summary.py +33 -0
  770. package/tui/widgets/prompt.py +935 -0
  771. package/tui/widgets/question.py +360 -0
  772. package/tui/widgets/session_grid_select.py +230 -0
  773. package/tui/widgets/session_summary.py +115 -0
  774. package/tui/widgets/session_tabs.py +194 -0
  775. package/tui/widgets/shell_result.py +35 -0
  776. package/tui/widgets/shell_terminal.py +40 -0
  777. package/tui/widgets/side_bar.py +124 -0
  778. package/tui/widgets/slash_complete.py +235 -0
  779. package/tui/widgets/strike_text.py +70 -0
  780. package/tui/widgets/subagent.py +287 -0
  781. package/tui/widgets/subagent_screen.py +715 -0
  782. package/tui/widgets/terminal.py +576 -0
  783. package/tui/widgets/terminal_tool.py +360 -0
  784. package/tui/widgets/throbber.py +100 -0
  785. package/tui/widgets/tool_call.py +432 -0
  786. package/tui/widgets/user_input.py +23 -0
  787. package/tui/widgets/version.py +5 -0
  788. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,3567 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import random
7
+ import re
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any, AsyncIterator, Optional
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Callable
14
+
15
+ from onecode.agent.agents.types import AgentPermission
16
+ from onecode.agent.cdh_loader import CdhProjectLoader
17
+ from onecode.agent.context import ContextManager
18
+ from onecode.agent.permissions_store import PermissionStore
19
+ from onecode.models.provider import ContentBlockType, ProviderRegistry
20
+
21
+ from onecode.agent.session import AgentSession
22
+ from onecode.memory import AgentMemory, MemoryLayer
23
+ from onecode.models.errors import ContextLengthError, TransientProviderError, safe_error_msg
24
+ from onecode.models.messages import StreamEvent, StreamEventType
25
+
26
+ logger = logging.getLogger("onecode.agent.engine")
27
+
28
+ # Subagent depth limit — subagents are leaf nodes by default (max depth = 1),
29
+ # controlled via `agent.max_subagent_depth` in onecode.config.yaml.
30
+ _MAX_SUBAGENT_DEPTH = 1
31
+
32
+ # Marker embedded in injected nudge messages for robust cleanup.
33
+ # Searching by content is resilient to context compaction, index shifts,
34
+ # and concurrent message insertion — unlike the index-based approach.
35
+ _INJECT_MARKER = "<!-- INJECTED_NUDGE -->"
36
+
37
+ # Maximum wall-clock seconds for a single subagent execution. Prevents
38
+ # hung subagents from blocking the parent engine indefinitely.
39
+ _SUBAGENT_TIMEOUT = 600
40
+
41
+
42
+ def _get_block_type(block: Any) -> str:
43
+ """Get content block type string, handling both dict and ContentBlock."""
44
+ if isinstance(block, dict):
45
+ return block.get("type", "")
46
+ return str(getattr(block, "type", "") or "")
47
+
48
+
49
+ def _get_block_text(block: Any) -> str:
50
+ """Get content block text, handling both dict and ContentBlock."""
51
+ if isinstance(block, dict):
52
+ return str(block.get("text", "") or block.get("content", "") or "")
53
+ text = getattr(block, "text", None) or getattr(block, "content", None)
54
+ return str(text or "")
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ToolEvent:
59
+ """Event emitted during the agent loop lifecycle (Clawd-Code style)."""
60
+ kind: str # "tool_use" | "tool_result" | "tool_error"
61
+ tool_name: str = ""
62
+ tool_input: dict | None = None
63
+ tool_output: Any = None
64
+ tool_use_id: str | None = None
65
+ is_error: bool = False
66
+ error: str | None = None
67
+
68
+
69
+ ToolEventHandler = Callable[[ToolEvent], None]
70
+
71
+
72
+ class TurnCancelledError(Exception):
73
+ """Raised when the user cancels the agent's turn mid-execution."""
74
+
75
+
76
+ TOOL_CALL_RE = re.compile(
77
+ r'<tool_call\s+name=["\']([^"\']+)["\']\s+id=["\']([^"\']+)["\']>(.*?)</tool_call>',
78
+ re.DOTALL,
79
+ )
80
+
81
+ # Structured tool call format used by the minimaxi / claude / Anthropic-style
82
+ # models. Looks like::
83
+ #
84
+ # <minimax:tool_call>
85
+ # <invoke name="Read">
86
+ # <parameter name="path">SPEC.md</parameter>
87
+ # </invoke>
88
+ # <invoke name="Bash">
89
+ # <parameter name="command">ls -la</parameter>
90
+ # <parameter name="timeout">30</parameter>
91
+ # </invoke>
92
+ # </minimax:tool_call>
93
+ #
94
+ # We match the outer block, then split into individual <invoke> elements
95
+ # inside ``_extract_minimax_tool_uses``.
96
+ MINIMAX_TOOL_CALL_RE = re.compile(
97
+ r'<minimax:tool_call>(.*?)</minimax:tool_call>',
98
+ re.DOTALL,
99
+ )
100
+
101
+ # An individual <invoke name="X">...</invoke> block. ``name`` is captured
102
+ # separately so we can call ``_parse_minimax_invoke_body`` on the inner XML.
103
+ _MINIMAX_INVOKE_RE = re.compile(
104
+ r'<invoke\s+name="([^"]+)">(.*?)</invoke>',
105
+ re.DOTALL,
106
+ )
107
+
108
+ # A single <parameter name="X">value</parameter> element. The value can span
109
+ # multiple lines, may contain entity-escaped angle brackets, and we tolerate
110
+ # either double or single quotes around the name.
111
+ _MINIMAX_PARAM_RE = re.compile(
112
+ r'<parameter\s+name=["\']([^"\']+)["\']>(.*?)</parameter>',
113
+ re.DOTALL,
114
+ )
115
+
116
+ THINKING_RE = re.compile(
117
+ r'<think(?:ing)?>(.*?)</think(?:ing)?>',
118
+ re.DOTALL,
119
+ )
120
+
121
+ _LEGACY_OPEN = "[TOOL_CALL]"
122
+ _LEGACY_CLOSE = "[/TOOL_CALL]"
123
+
124
+
125
+ def _parse_minimax_invoke_body(body: str) -> dict:
126
+ """Parse the inner XML of a single ``<invoke name="X">`` block.
127
+
128
+ Returns a dict ``{"name": str, "arguments": {param_name: value, ...}}``.
129
+ The ``arguments`` dict is empty if no ``<parameter>`` children are found.
130
+ """
131
+ body = body.strip()
132
+ arguments: dict = {}
133
+ for m in _MINIMAX_PARAM_RE.finditer(body):
134
+ param_name = m.group(1)
135
+ raw_value = m.group(2)
136
+ arguments[param_name] = _unescape_xml(raw_value)
137
+ return arguments
138
+
139
+
140
+ def _unescape_xml(s: str) -> str:
141
+ """Reverse the common XML / HTML entity escapes.
142
+
143
+ We keep this conservative — the five predefined entities and the numeric
144
+ ones — so we never accidentally mangle content that legitimately contains
145
+ ampersands.
146
+ """
147
+ return (
148
+ s.replace("&lt;", "<")
149
+ .replace("&gt;", ">")
150
+ .replace("&quot;", '"')
151
+ .replace("&apos;", "'")
152
+ .replace("&amp;", "&")
153
+ )
154
+
155
+
156
+ def _extract_minimax_tool_uses(text: str, id_start: int = 0) -> tuple[list[dict], str, int]:
157
+ """Extract ``<minimax:tool_call>...</minimax:tool_call>`` blocks.
158
+
159
+ Returns ``(tool_uses, cleaned_text, next_id)`` where ``tool_uses`` is a
160
+ list of ``{id, name, input}`` dicts and ``cleaned_text`` has every
161
+ matched block removed. ``id_start`` is the next available tool id
162
+ counter (caller-owned, see ``AgentEngine._tool_id_counter``); the next
163
+ free id is returned in the tuple so callers can pass it to subsequent
164
+ extractions without losing uniqueness.
165
+ """
166
+ tool_uses: list[dict] = []
167
+ cleaned = text
168
+ counter = id_start
169
+ while True:
170
+ m = MINIMAX_TOOL_CALL_RE.search(cleaned)
171
+ if m is None:
172
+ break
173
+ block_body = m.group(1)
174
+ for inv in _MINIMAX_INVOKE_RE.finditer(block_body):
175
+ name = inv.group(1)
176
+ arguments = _parse_minimax_invoke_body(inv.group(2))
177
+ counter += 1
178
+ tool_uses.append({
179
+ "id": f"minimax-{counter}",
180
+ "name": name,
181
+ "input": arguments,
182
+ })
183
+ cleaned = cleaned[:m.start()] + cleaned[m.end():]
184
+ return tool_uses, cleaned, counter
185
+
186
+
187
+ def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
188
+ if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
189
+ return None
190
+ depth = 0
191
+ i = open_pos
192
+ n = len(text)
193
+ while i < n:
194
+ c = text[i]
195
+ if c == "{":
196
+ depth += 1
197
+ i += 1
198
+ elif c == "}":
199
+ depth -= 1
200
+ if depth == 0:
201
+ return i
202
+ i += 1
203
+ elif c in ('"', "'"):
204
+ quote = c
205
+ i += 1
206
+ while i < n and text[i] != quote:
207
+ if text[i] == "\\" and i + 1 < n:
208
+ i += 2
209
+ else:
210
+ i += 1
211
+ i += 1
212
+ elif c == "/" and i + 1 < n and text[i + 1] == "/":
213
+ while i < n and text[i] != "\n":
214
+ i += 1
215
+ else:
216
+ i += 1
217
+ return None
218
+
219
+
220
+ def _parse_legacy_tool_body(body: str) -> dict:
221
+ body = body.strip()
222
+ if not body or not body.startswith("{"):
223
+ return {"name": None, "arguments": {}}
224
+ body_close = _scan_balanced_braces(body, 0)
225
+ if body_close is None:
226
+ return {"name": None, "arguments": {}}
227
+ inner = body[1:body_close]
228
+ name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
229
+ name = name_match.group(1) if name_match else None
230
+ arguments: dict = {}
231
+ args_match = re.search(r'args\s*=>\s*\{', inner)
232
+ if args_match:
233
+ args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
234
+ if args_outer is not None:
235
+ args_body = inner[args_match.end():args_outer]
236
+ for am in re.finditer(
237
+ r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
238
+ ):
239
+ arguments[am.group(1)] = am.group(2)
240
+ return {"name": name, "arguments": arguments}
241
+
242
+
243
+ def _extract_legacy_tool_uses(
244
+ text: str, id_start: int = 0
245
+ ) -> tuple[list[dict], str, int]:
246
+ """Extract all [TOOL_CALL]...[/TOOL_CALL] blocks from text.
247
+
248
+ Returns (tool_uses, cleaned_text, next_id) where tool_uses has
249
+ [{id, name, input}, ...] and cleaned_text has all markers removed.
250
+ ``id_start`` lets the caller share a monotonic counter across multiple
251
+ extraction passes; the next free id is returned so callers can pass it
252
+ forward.
253
+ """
254
+ tool_uses: list[dict] = []
255
+ cleaned = text
256
+ counter = id_start
257
+ while True:
258
+ open_idx = cleaned.find(_LEGACY_OPEN)
259
+ if open_idx < 0:
260
+ break
261
+ body_start = open_idx + len(_LEGACY_OPEN)
262
+ close_idx = cleaned.find(_LEGACY_CLOSE, body_start)
263
+ if close_idx < 0:
264
+ # Unclosed marker — stop looking
265
+ break
266
+ body = cleaned[body_start:close_idx]
267
+ span_end = close_idx + len(_LEGACY_CLOSE)
268
+ parsed = _parse_legacy_tool_body(body)
269
+ name = parsed.get("name")
270
+ if name:
271
+ counter += 1
272
+ tool_uses.append({
273
+ "id": f"legacy-{counter}",
274
+ "name": name,
275
+ "input": parsed.get("arguments", {}),
276
+ })
277
+ cleaned = cleaned[:open_idx] + cleaned[span_end:]
278
+ return tool_uses, cleaned, counter
279
+
280
+
281
+ _TODO_STATUSES = {"pending", "in_progress", "completed"}
282
+
283
+
284
+ class TodoManager:
285
+ """Unified todo manager (formerly TodoManager).
286
+
287
+ Each todo has: id, subject, description, activeForm, status, owner, blocks,
288
+ blockedBy, metadata, output. Supports dependency tracking and progress
289
+ display in the TUI Plan sidebar.
290
+
291
+ Persistence format (``to_dict``/``from_dict``) accepts both the new
292
+ single-list layout and the legacy ``{"tasks": [...], "todos": [...]}``
293
+ layout for backwards compatibility with existing session files.
294
+ """
295
+
296
+ def __init__(self, on_change: Callable[[], None] | None = None):
297
+ self._todos: dict[str, dict] = {}
298
+ self._order: list[str] = []
299
+ self._id_counter = 0
300
+ self._on_change = on_change
301
+
302
+ def _mark_dirty(self) -> None:
303
+ if self._on_change:
304
+ self._on_change()
305
+
306
+ def _next_id(self) -> str:
307
+ self._id_counter += 1
308
+ return f"t{self._id_counter}"
309
+
310
+ # ── Todo CRUD ──
311
+
312
+ def create_todo(
313
+ self,
314
+ subject: str,
315
+ description: str = "",
316
+ active_form: str = "",
317
+ metadata: dict | None = None,
318
+ ) -> dict:
319
+ """Create a todo with dependency tracking support."""
320
+ todo_id = self._next_id()
321
+ todo = {
322
+ "id": todo_id,
323
+ "_order": self._id_counter,
324
+ "subject": subject,
325
+ "description": description,
326
+ "activeForm": active_form,
327
+ "status": "pending",
328
+ "owner": None,
329
+ "blocks": [],
330
+ "blockedBy": [],
331
+ "metadata": dict(metadata or {}),
332
+ "output": "",
333
+ }
334
+ self._todos[todo_id] = todo
335
+ self._order.append(todo_id)
336
+ self._mark_dirty()
337
+ return todo
338
+
339
+ def get_todo(self, todo_id: str) -> dict | None:
340
+ return self._todos.get(todo_id)
341
+
342
+ def list_todos(self) -> list[dict]:
343
+ """List all todos in creation order."""
344
+ return [self._todos[tid] for tid in self._order if tid in self._todos]
345
+
346
+ def update_todo(self, todo_id: str, **fields) -> dict | None:
347
+ """Update todo fields. Supports: subject, description, activeForm, status,
348
+ owner, metadata, addBlocks, addBlockedBy. Returns updated todo or None."""
349
+ todo = self._todos.get(todo_id)
350
+ if todo is None:
351
+ return None
352
+
353
+ updated = []
354
+
355
+ for field in ("subject", "description", "activeForm", "owner"):
356
+ if field in fields:
357
+ v = fields[field]
358
+ if isinstance(v, str) and v != todo.get(field):
359
+ todo[field] = v
360
+ updated.append(field)
361
+
362
+ if "status" in fields:
363
+ status = fields["status"]
364
+ if status == "deleted":
365
+ self._todos.pop(todo_id, None)
366
+ if todo_id in self._order:
367
+ self._order.remove(todo_id)
368
+ self._mark_dirty()
369
+ return {"id": todo_id, "deleted": True}
370
+ if status in _TODO_STATUSES and status != todo.get("status"):
371
+ todo["status"] = status
372
+ updated.append("status")
373
+
374
+ for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")):
375
+ if input_key in fields:
376
+ ids = fields[input_key]
377
+ if isinstance(ids, list):
378
+ cur = list(todo.get(rel_field) or [])
379
+ for x in ids:
380
+ if isinstance(x, str) and x not in cur:
381
+ cur.append(x)
382
+ if cur != todo.get(rel_field):
383
+ todo[rel_field] = cur
384
+ updated.append(rel_field)
385
+
386
+ if "metadata" in fields:
387
+ md = fields["metadata"]
388
+ if isinstance(md, dict):
389
+ existing = dict(todo.get("metadata") or {})
390
+ for k, v in md.items():
391
+ if v is None:
392
+ existing.pop(k, None)
393
+ else:
394
+ existing[k] = v
395
+ todo["metadata"] = existing
396
+ updated.append("metadata")
397
+
398
+ if "output" in fields:
399
+ v = fields["output"]
400
+ if isinstance(v, str):
401
+ todo["output"] = v
402
+ updated.append("output")
403
+
404
+ self._mark_dirty()
405
+ return todo
406
+
407
+ def get_todo_output(self, todo_id: str) -> dict:
408
+ """Get output for a todo."""
409
+ todo = self._todos.get(todo_id)
410
+ if todo is None:
411
+ return {"retrieval_status": "not_found", "task": None}
412
+ output = str(todo.get("output") or "")
413
+ return {
414
+ "retrieval_status": "success" if output else "not_ready",
415
+ "task": {
416
+ "task_id": todo_id,
417
+ "task_type": "todo_list",
418
+ "status": todo.get("status"),
419
+ "description": todo.get("description"),
420
+ "output": output,
421
+ },
422
+ }
423
+
424
+ def clear_todos(self) -> None:
425
+ self._todos.clear()
426
+ self._order.clear()
427
+ self._id_counter = 0
428
+ self._mark_dirty()
429
+
430
+ def clear_completed(self) -> None:
431
+ completed_ids = [tid for tid in self._order if tid in self._todos and self._todos[tid].get("status") == "completed"]
432
+ for tid in completed_ids:
433
+ self._todos.pop(tid, None)
434
+ self._order.remove(tid)
435
+ if completed_ids:
436
+ self._mark_dirty()
437
+
438
+ # ── Serialization ──
439
+
440
+ def to_dict(self) -> dict:
441
+ """Serialize todos for persistence."""
442
+ return {
443
+ "todos": [dict(self._todos[tid]) for tid in self._order if tid in self._todos],
444
+ "id_counter": self._id_counter,
445
+ }
446
+
447
+ @classmethod
448
+ def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> "TodoManager":
449
+ """Restore todo manager from saved dict.
450
+
451
+ Accepts the unified ``{"todos": [...]}`` layout (preferred) and the
452
+ legacy ``{"tasks": [...], "todos": [...]}`` layout where ``todos``
453
+ entries may use the old ``{text, done}`` shape — these are converted
454
+ on the fly to the new ``{subject, status}`` shape.
455
+ """
456
+ tm = cls(on_change=on_change)
457
+ tm.reload_from_dict(data)
458
+ return tm
459
+
460
+ def reload_from_dict(self, data: dict) -> None:
461
+ """Reload state from *data* in place, preserving the instance identity.
462
+
463
+ This is critical because tool instances (``TodoCreateTool`` etc.)
464
+ hold a direct reference to the same ``TodoManager`` object. If we
465
+ replaced ``self._todo_manager`` with a new instance, the tools
466
+ would silently write to the stale one while the engine reads from
467
+ the fresh one — creating the illusion of success but never
468
+ persisting or emitting plan updates.
469
+ """
470
+ self._todos.clear()
471
+ self._order.clear()
472
+ self._id_counter = data.get("id_counter", 0)
473
+
474
+ raw_todos: list[dict] = []
475
+ if "todos" in data:
476
+ raw_todos.extend(data.get("todos") or [])
477
+ if "tasks" in data:
478
+ raw_todos.extend(data.get("tasks") or [])
479
+
480
+ for t in raw_todos:
481
+ todo = dict(t)
482
+ if "subject" not in todo and "text" in todo:
483
+ todo["subject"] = todo.pop("text")
484
+ if "status" not in todo and "done" in todo:
485
+ todo["status"] = "completed" if todo.pop("done") else "pending"
486
+ todo.setdefault("status", "pending")
487
+ tid = todo.get("id") or self._next_id()
488
+ todo["id"] = tid
489
+ todo.setdefault("_order", self._id_counter)
490
+ self._todos[tid] = todo
491
+ if tid not in self._order:
492
+ self._order.append(tid)
493
+
494
+
495
+ # Tools that require a task plan before execution (plan gate)
496
+ _EXECUTION_TOOLS: frozenset[str] = frozenset({
497
+ "Write", "Edit", "Insert", "ApplyPatch", "Bash",
498
+ })
499
+
500
+
501
+ class AgentEngine:
502
+ def __init__(self, app, project_dir: Path | None = None, perm_store: PermissionStore | None = None):
503
+ from onecode.agent.tools.file_ops import ToolFactory
504
+ from onecode.agent.agents.types import BuildAgent
505
+ from onecode.agent.hooks import HookManager
506
+ from onecode.skills.loader import SkillLoader
507
+ from onecode.mcp.manager import MCPManager
508
+ from onecode.agent.tools.cron_tools import CronScheduler
509
+ from onecode.agent.tools.lsp_tools import LSPTool
510
+ from onecode.agent.tools.config_tool import ConfigReadTool, ConfigWriteTool
511
+ from onecode.agent.tools.communication_tools import SendMessageTool
512
+ self.app = app
513
+ self._project_dir = (project_dir or Path.cwd()).resolve()
514
+ self.context = ContextManager()
515
+ self.file_ops = ToolFactory.create_file_ops(self._project_dir)
516
+ self.shell = ToolFactory.create_shell(self._project_dir)
517
+ self.current_agent: AgentConfig = BuildAgent() # noqa: F821
518
+ self.iterations = 0
519
+ self.total_tokens = 0
520
+ self._skills_loaded = False
521
+ self._session: Optional[AgentSession] = None
522
+ self._memory = AgentMemory()
523
+ self._hooks = HookManager()
524
+ self._todo_manager = TodoManager(on_change=self._on_todo_change)
525
+ self._plan_dirty: bool = False
526
+ # Plan mode denial counter — reset per session
527
+ self._plan_denial_count: int = 0
528
+ # Per-tool denial counters for loop protection (any agent/mode)
529
+ self._tool_denial_count: dict[str, int] = {}
530
+ # Path of the plan document saved when the plan agent submits for approval
531
+ self._approved_plan_path: str | None = None
532
+ # Cache of the last plan snapshot that was emitted to the TUI. Used
533
+ # by ``_emit_plan_update`` to dedupe redundant emits when the todos
534
+ # have not actually changed between turns. The first emit of a
535
+ # session/cycle always wins (cache starts empty).
536
+ self._last_emitted_plan: tuple = ()
537
+ self._project_config: dict = {}
538
+ self._project_context_loaded = False
539
+ self._pending_approval: dict | None = None # {tool_call, result_key}
540
+ self._last_user_msg: str | None = None # Last SendMessage visible to user
541
+
542
+ # Clawd-Code subsystems
543
+ self._skill_loader = SkillLoader()
544
+
545
+ # Loop system (L2 Verification + L3 Event)
546
+ from onecode.config import load_config
547
+ _cfg = load_config()
548
+ self._cfg = _cfg
549
+
550
+ self._mcp = MCPManager(
551
+ timeout=_cfg.mcp.timeout,
552
+ heartbeat_interval=_cfg.mcp.heartbeat_interval,
553
+ )
554
+ self._cron_scheduler = CronScheduler()
555
+ self._lsp_tool = LSPTool()
556
+
557
+ # Register default CloudBase MCP entry (shows in ``mcp list`` even
558
+ # without credentials — actual connect happens when skill loads).
559
+ from onecode.mcp.cloudbase import ensure_configured as _ensure_cb
560
+ _ensure_cb(self._mcp)
561
+ self._verification_loop: Optional["VerificationLoop"] = None # noqa: F821
562
+ self._event_bridge: Optional["EventBridge"] = None # noqa: F821
563
+ if _cfg.loops.verification.enabled:
564
+ agent_type = getattr(app, 'current_agent', None)
565
+ agent_name = getattr(agent_type, 'name', 'build') if agent_type else 'build'
566
+ vcfg = _cfg.loops.verification.for_agent(agent_name)
567
+ if vcfg.enabled:
568
+ from onecode.verification import VerificationLoop
569
+ from onecode.verification.gates import LintGate, TypeGate, TestGate
570
+ self._verification_loop = VerificationLoop(policy=vcfg.policy)
571
+ self._verification_loop.activate()
572
+ if "lint" in vcfg.gates:
573
+ self._verification_loop.register_gate(LintGate())
574
+ if "type" in vcfg.gates:
575
+ self._verification_loop.register_gate(TypeGate())
576
+ if "test" in vcfg.gates:
577
+ self._verification_loop.register_gate(TestGate())
578
+ if _cfg.loops.event.enabled:
579
+ from onecode.agent.event_bridge import EventBridge
580
+ self._event_bridge = EventBridge(bus=None)
581
+ app_config = getattr(app, 'config', None)
582
+ self._config_tool_read = ConfigReadTool(app_config) if app_config else None
583
+ self._config_tool_write = ConfigWriteTool(app_config) if app_config else None
584
+
585
+ # Codebase engine (lazy init)
586
+ self._codebase_engine: Optional["CodebaseEngine"] = None # noqa: F821
587
+
588
+ # Tool registry (Clawd-Code pattern)
589
+ self._tool_registry = self._build_tool_registry()
590
+ self._send_message_tool = SendMessageTool()
591
+
592
+ # Per-turn usage tracking (Clawd-Code style)
593
+ self._turn_usages: list[dict[str, int]] = []
594
+
595
+ # Turn-level issue tracking — collects provider errors, context overflows,
596
+ # verification failures across all turns so the final session summary
597
+ # can report every problem encountered, not just the first one.
598
+ self._turn_events: list[dict] = []
599
+
600
+ # Event callbacks
601
+ self.on_event: ToolEventHandler | None = None
602
+ self.on_text_chunk: Callable[[str], None] | None = None
603
+ self.on_tool_call_delta: Callable[[str, str, str], None] | None = None
604
+ self.on_thinking: Callable[[str], None] | None = None
605
+ self._streaming_used: bool = False
606
+ self._pending_thinking_blocks: list[str] = []
607
+
608
+ # Cancellation support
609
+ self._cancelled: bool = False
610
+
611
+ # Subagent depth limit — subagents are leaf nodes (max depth = 1).
612
+ self._subagent_depth: int = 0
613
+
614
+ # ContextLengthError crisis flag — when set, skip codebase/memory
615
+ # auto-injection to avoid "compact → re-inject → compact" loops.
616
+ self._ctx_crisis: bool = False
617
+
618
+ # Verification retry counter per session.
619
+ self._verification_fail_count: int = 0
620
+
621
+ # Store last tool results by tool_use_id for TurnRecord assembly.
622
+ self._last_tool_results: dict[str, str] = {}
623
+
624
+ # Skip codebase auto-retrieval / long-term memory recall inside
625
+ # subagent engines. Both are project-seeding steps meant for the
626
+ # top-level chat loop; a subagent already receives a fully-formed
627
+ # explicit prompt from the parent and does NOT need toEmbed/index
628
+ # the whole codebase or recall memories again — that path
629
+ # previously called ``CodebaseIndexer.index()`` (a long
630
+ # synchronous loop, blocking the event loop and cancel) plus
631
+ # ``CodebaseRetriever._retrieve_embedding()`` (one httpx call per
632
+ # chunk, each with a 30s timeout, so ~minutes of dead time), which
633
+ # is the root cause of the "subagent shows no stream output and
634
+ # can't be stopped" hang observed in production.
635
+ self._disable_retrieval: bool = False
636
+
637
+ # Monotonic tool-call id counter — shared across every
638
+ # ``chat_stream`` turn so the same id is never reused even when the
639
+ # model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
640
+ # Touched only by ``_extract_*_tool_uses`` through the engine
641
+ # instance, not by the module-level helpers (which take an explicit
642
+ # ``id_start`` for testability).
643
+ self._tool_id_counter: int = 0
644
+
645
+ # Subagent engines spawned by this engine — we track them so
646
+ # cancelling the parent also cancels in-flight subagents.
647
+ self._child_engines: list[AgentEngine] = []
648
+
649
+ # Permission overrides shared across subagents
650
+ self._perm_store: PermissionStore = perm_store or PermissionStore()
651
+
652
+ # Plan gate: mode-aware enforcement based on agent type
653
+ # "hard" (plan mode) rejects execution tools without plan
654
+ # "soft" (build/solo mode) suggests planning but doesn't block
655
+ # "off" (agents with permission_task=DENY) no enforcement
656
+ self._plan_gate_mode: str = "off"
657
+
658
+ # Flag set when exiting Plan mode — suppresses auto-advancement of todos
659
+ # until user explicitly starts executing real work in the new mode.
660
+ self._just_exited_plan_mode: bool = False
661
+
662
+ # ReAct state tracking
663
+ self._react_phase: str = "thought" # "thought" | "action" | "observation"
664
+ self._direct_execution_count: int = 0 # Track direct tool use for routing-decision reminder
665
+
666
+ # Consecutive turns with zero tool_uses — if the LLM repeatedly
667
+ # refuses to call tools despite pending todos, we cap the loop
668
+ # instead of cycling until max_turns is exhausted.
669
+ self._empty_tool_turns: int = 0
670
+
671
+ # Track injected nudge messages for cleanup after chat_stream exits.
672
+ self._injected_msg_indices: list[int] = []
673
+
674
+ # Checkpoint manager for protecting file state before destructive operations
675
+ from onecode.agent.checkpoint import CheckpointManager
676
+ self._checkpoint_manager = CheckpointManager(self._project_dir)
677
+
678
+ def _build_tool_registry(self) -> ToolRegistry: # noqa: F821
679
+ from onecode.agent.tools.registry import ToolRegistry
680
+ from onecode.agent.tools.file_tools import ReadTool, WriteTool, EditTool, InsertTool, UndoEditTool, GlobTool, GrepTool, ListTool
681
+ from onecode.agent.tools.apply_patch_tool import ApplyPatchTool
682
+ from onecode.agent.tools.bash_tool import BashTool
683
+ from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
684
+ from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
685
+ from onecode.agent.tools.todo_tools import (TodoCreateTool, TodoGetTool, TodoListTool, TodoUpdateTool,
686
+ TodoOutputTool, TodoStopTool, TodoClearTool)
687
+ from onecode.agent.tools.agent_tools import AgentTool, TaskTool
688
+ from onecode.agent.tools.skill_tools import SkillTool
689
+ from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
690
+ from onecode.agent.tools.cron_tools import CronCreateTool, CronListTool, CronRemoveTool
691
+ from onecode.agent.tools.git_tools import WorktreeTool
692
+ registry = ToolRegistry()
693
+ # File tools
694
+ registry.register(ReadTool(self.file_ops))
695
+ registry.register(WriteTool(self.file_ops))
696
+ registry.register(EditTool(self.file_ops))
697
+ registry.register(InsertTool(self.file_ops))
698
+ registry.register(UndoEditTool(self.file_ops))
699
+ registry.register(GlobTool(self.file_ops))
700
+ registry.register(GrepTool(self.file_ops))
701
+ registry.register(ListTool(self.file_ops))
702
+ registry.register(ApplyPatchTool(self.file_ops))
703
+ # Shell
704
+ registry.register(BashTool(self.shell))
705
+ # Web
706
+ registry.register(WebFetchTool())
707
+ registry.register(WebSearchTool())
708
+ # Communication
709
+ self._send_message_tool = SendMessageTool()
710
+ registry.register(self._send_message_tool)
711
+ registry.register(AskUserTool())
712
+ registry.register(ToolSearchTool(registry))
713
+ # Task management
714
+ registry.register(TodoCreateTool(self._todo_manager))
715
+ registry.register(TodoGetTool(self._todo_manager))
716
+ registry.register(TodoListTool(self._todo_manager))
717
+ registry.register(TodoUpdateTool(self._todo_manager))
718
+ registry.register(TodoOutputTool(self._todo_manager))
719
+ registry.register(TodoStopTool(self._todo_manager))
720
+ registry.register(TodoClearTool(self._todo_manager))
721
+ # Agent tools
722
+ registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
723
+ registry.register(TaskTool(self._spawn_subagent_async))
724
+
725
+ # Skill tool (Clawd-Code pattern)
726
+ registry.register(SkillTool(self._skill_loader))
727
+
728
+ # MCP tools (Clawd-Code pattern)
729
+ registry.register(MCPToolTool(self._mcp))
730
+ registry.register(MCPResourcesTool(self._mcp))
731
+
732
+ # LSP tool (Clawd-Code pattern)
733
+ registry.register(self._lsp_tool)
734
+
735
+ # Cron tools (Clawd-Code pattern)
736
+ registry.register(CronCreateTool(self._cron_scheduler))
737
+ registry.register(CronListTool(self._cron_scheduler))
738
+ registry.register(CronRemoveTool(self._cron_scheduler))
739
+
740
+ # Git worktree tool (Clawd-Code pattern)
741
+ registry.register(WorktreeTool(workspace_root=self._project_dir))
742
+
743
+ # Config tools (Clawd-Code pattern)
744
+ if self._config_tool_read:
745
+ registry.register(self._config_tool_read)
746
+ if self._config_tool_write:
747
+ registry.register(self._config_tool_write)
748
+
749
+ # Codebase search tool (lazy — engine initialised on first search)
750
+ from onecode.codebase.tools import CodebaseSearchTool
751
+ registry.register(CodebaseSearchTool(lambda: self._codebase_engine))
752
+ return registry
753
+
754
+ def _is_plan_mode(self) -> bool:
755
+ from onecode.agent.agents.types import AgentPermission
756
+ return (self.current_agent is not None
757
+ and self.current_agent.permission_edit == AgentPermission.DENY
758
+ and self.current_agent.permission_bash == AgentPermission.DENY)
759
+
760
+ def _resolve_plan_gate_mode(self) -> str:
761
+ """Determine plan gate strictness based on current agent type.
762
+
763
+ Returns "hard" (reject execution tools without plan), "soft" (nudge only),
764
+ or "off" (no enforcement).
765
+ """
766
+ from onecode.agent.agents.types import AgentPermission
767
+ if self.current_agent.permission_task == AgentPermission.DENY:
768
+ return "off"
769
+ if self.current_agent.name == "plan":
770
+ return "hard"
771
+ return "soft"
772
+
773
+ def _has_pending_todos(self) -> bool:
774
+ return any(
775
+ t.get("status") in ("pending", "in_progress")
776
+ for t in self._todo_manager.list_todos()
777
+ )
778
+
779
+ def _notify_event(self, event: ToolEvent) -> None:
780
+ """Emit a ToolEvent to the registered callback (Clawd-Code pattern)."""
781
+ if self.on_event:
782
+ try:
783
+ self.on_event(event)
784
+ except Exception as e:
785
+ logger.warning("ToolEvent callback failed: %s", e)
786
+
787
+ def _inject_nudge(self, lines: list[str]) -> None:
788
+ """Inject a user nudge message for later cleanup (marker-based)."""
789
+ self.context.add_message(
790
+ "user",
791
+ [{"type": "text", "text": f"{_INJECT_MARKER}\n" + "\n".join(lines)}],
792
+ )
793
+
794
+ def _cleanup_injected_messages(self) -> None:
795
+ """Remove all injected nudge messages by marker content.
796
+
797
+ Uses the ``_INJECT_MARKER`` string embedded in message text, which
798
+ is resilient to context compaction and index shifts — unlike the
799
+ previous index-based approach that could corrupt the message list
800
+ when markers referred to stale positions.
801
+ """
802
+ marker = _INJECT_MARKER
803
+ kept: list = []
804
+ removed_tokens = 0
805
+ for m in self.context.messages:
806
+ content_str = ""
807
+ if isinstance(m.content, str):
808
+ content_str = m.content
809
+ elif isinstance(m.content, list):
810
+ content_str = " ".join(
811
+ str(b.get("text", "")) for b in m.content if isinstance(b, dict)
812
+ )
813
+ if marker in content_str:
814
+ removed_tokens += self.context._estimate_message_tokens(m)
815
+ else:
816
+ kept.append(m)
817
+ self.context.messages = kept
818
+ self.context._token_count -= removed_tokens
819
+ self._injected_msg_indices.clear()
820
+
821
+ def _emit_text_chunks(self, text: str, chunk_size: int = 12) -> None:
822
+ """Emit user-visible text in small chunks (Clawd-Code pattern)."""
823
+ if self.on_text_chunk is None or not text:
824
+ return
825
+ if chunk_size <= 0:
826
+ chunk_size = len(text)
827
+ for idx in range(0, len(text), chunk_size):
828
+ try:
829
+ self.on_text_chunk(text[idx:idx + chunk_size])
830
+ except Exception as e:
831
+ logger.warning("on_text_chunk failed: %s", e)
832
+ return
833
+
834
+ def _emit_plan_update(self) -> list[StreamEvent]:
835
+ """Emit a plan update event from current todos, deduped against last emit.
836
+
837
+ Returns an empty list when the plan snapshot is byte-identical to the
838
+ last emission, so callers can no-op without paying the JSON-RPC + TUI
839
+ widget-recompose cost. The cache is a tuple of ``(content, status,
840
+ priority)`` triples — order-sensitive, so reordering the todo list
841
+ also triggers a re-emit.
842
+ """
843
+ entries = [
844
+ (
845
+ t.get("subject") or t.get("description", ""),
846
+ t.get("status", "pending"),
847
+ t.get("metadata", {}).get("priority", "medium"),
848
+ )
849
+ for t in self._todo_manager.list_todos()
850
+ ]
851
+ if entries == self._last_emitted_plan:
852
+ return []
853
+ self._last_emitted_plan = entries
854
+ # Convert the cached tuples back to wire-format dicts for the stream
855
+ # event. The TUI side expects dicts; the dedupe cache stays tuples
856
+ # to avoid the per-emit dict-construction cost on the hot path.
857
+ wire_entries = [
858
+ {"content": c, "status": s, "priority": p}
859
+ for (c, s, p) in entries
860
+ ]
861
+ return [StreamEvent.plan(wire_entries)]
862
+
863
+ def _auto_advance_after_spawn(self, subagent_prompt: str) -> None:
864
+ """After a Spawn subagent completes, auto-advance todo status.
865
+
866
+ Finds the first ``in_progress`` todo (typically the one the subagent
867
+ was working on) and marks it ``completed``. If another ``pending``
868
+ todo remains, injects a user message into the LLM context directing
869
+ the agent to continue — no need to wait for the LLM to spontaneously
870
+ call ``TodoUpdate`` then ``Spawn`` again.
871
+ """
872
+ todos = self._todo_manager.list_todos()
873
+ in_progress = [t for t in todos if t.get("status") == "in_progress"]
874
+ if in_progress:
875
+ tid = in_progress[0]["id"]
876
+ self._todo_manager.update_todo(tid, status="completed")
877
+
878
+ pending = [t for t in self._todo_manager.list_todos() if t.get("status") == "pending"]
879
+ if pending:
880
+ lines = ["# Continue working — remaining todos", ""]
881
+ for t in pending:
882
+ sub = t.get("subject", "")
883
+ desc = t.get("description", "")
884
+ if desc and desc != sub:
885
+ lines.append(f"- `{t['id']}`: {sub} — {desc}")
886
+ else:
887
+ lines.append(f"- `{t['id']}`: {sub}")
888
+ lines.append("")
889
+ lines.append("Continue with the next pending todo above. Do NOT stop.")
890
+ body = "\n".join(lines)
891
+ self.context.add_message("user", [{"type": "text", "text": body}])
892
+
893
+ def _refresh_pending_todos_nudge(self) -> None:
894
+ """Inject a reminder that nudges the agent to advance unfinished todos.
895
+
896
+ Called at the start of each ReAct turn. If there are pending or
897
+ in-progress todos, a strong ``<!-- PENDING_TODOS -->`` section is
898
+ injected into the system context directing the agent to continue
899
+ working rather than summarising or stopping early.
900
+
901
+ When no todos are open, any stale nudge is removed so the context
902
+ stays clean.
903
+ """
904
+ marker = "<!-- PENDING_TODOS -->"
905
+ open_todos = [t for t in self._todo_manager.list_todos()
906
+ if t.get("status") in ("pending", "in_progress")]
907
+ if not open_todos:
908
+ self.context.remove_system_by_marker(marker)
909
+ return
910
+
911
+ in_progress = [t for t in open_todos if t.get("status") == "in_progress"]
912
+ pending = [t for t in open_todos if t.get("status") == "pending"]
913
+
914
+ def _label(t: dict) -> str:
915
+ sid = t.get("id", "?")
916
+ sub = t.get("subject") or t.get("description", "")
917
+ owner = t.get("owner")
918
+ owner_part = f" (owner={owner})" if owner else ""
919
+ return f"- `{sid}`{owner_part}: {sub}"
920
+
921
+ lines: list[str] = []
922
+ total = len(in_progress) + len(pending)
923
+ lines.append(f"## ⚠️ Open todos ({total}) — you MUST continue working on these")
924
+ lines.append("Do NOT end your turn until ALL todos below are completed.")
925
+ if in_progress:
926
+ labels = "\n".join(_label(t) for t in in_progress)
927
+ lines.append(f"\nIn progress:\n{labels}")
928
+ if pending:
929
+ labels = "\n".join(_label(t) for t in pending)
930
+ lines.append(f"\nPending:\n{labels}")
931
+ if not self._is_plan_mode():
932
+ lines.append(
933
+ "\nFor each completed todo, call TodoUpdate(status=\"completed\") "
934
+ "immediately. Then proceed to the next pending todo. "
935
+ "Do NOT summarise or stop — keep working."
936
+ )
937
+ else:
938
+ lines.append(
939
+ "\nProceed to the next pending task. "
940
+ "Do NOT summarise or stop — keep working."
941
+ )
942
+ body = f"{marker}\n" + "\n".join(lines)
943
+ if not self.context.replace_system_section(marker, body):
944
+ self.context.insert_system_before_non_system(body)
945
+
946
+ def _on_todo_change(self) -> None:
947
+ """Callback invoked by TodoManager whenever todos mutate.
948
+
949
+ Sets a dirty flag so the next opportunity in ``chat_stream`` will
950
+ emit a fresh plan event. Decoupling the callback from the emit
951
+ keeps the public API synchronous and avoids re-entrancy.
952
+
953
+ Subagents have an isolated TodoManager and (since Todo tools are
954
+ denied via disallowed_tools for subagents) cannot mutate the parent's
955
+ shared plan, so this callback only fires for the main agent's own
956
+ TodoManager.
957
+
958
+ Also persists todos to ``.cdh/todos.json`` immediately so they
959
+ survive a crash or Ctrl+C before the next ACP turn boundary.
960
+ """
961
+ self._plan_dirty = True
962
+ self.save_todos_to_project()
963
+
964
+ @property
965
+ def _workspace(self) -> Path:
966
+ return self._project_dir
967
+
968
+ def _get_affected_files(self, tool_name: str, tool_input: dict) -> list[str]:
969
+ """Extract list of file paths affected by a tool call."""
970
+ if tool_name in ("Edit", "Write", "Insert", "Read", "Glob", "Grep", "List"):
971
+ path = tool_input.get("path", "")
972
+ return [path] if path else []
973
+ if tool_name == "Bash":
974
+ cmd = tool_input.get("command", "")
975
+ files = []
976
+ for part in cmd.split():
977
+ if self._project_dir.exists() and (self._project_dir / part).exists():
978
+ if (self._project_dir / part).is_file():
979
+ files.append(part)
980
+ return files
981
+ if tool_name == "ApplyPatch":
982
+ content = tool_input.get("patch", "") or tool_input.get("content", "")
983
+ import re
984
+ paths = re.findall(r'^(?:---|\+\+\+) [ab]/(.+)$', content, re.MULTILINE)
985
+ return list(set(paths))
986
+ return []
987
+
988
+ def set_agent(self, agent_type: str, keep_todos: bool = False) -> None:
989
+ from onecode.agent.agents.types import (
990
+ AgentMode,
991
+ AgentPermission,
992
+ BUILD_AGENT_INSTRUCTIONS,
993
+ PLAN_AGENT_INSTRUCTIONS,
994
+ SOLO_AGENT_INSTRUCTIONS,
995
+ EXPLORE_AGENT_INSTRUCTIONS,
996
+ SUBAGENT_CONSTRAINTS,
997
+ create_agent,
998
+ PLAN_GATE_HARD,
999
+ PLAN_GATE_SOFT,
1000
+ filter_tool_descriptions,
1001
+ )
1002
+ was_plan_mode = self._is_plan_mode()
1003
+ self.current_agent = create_agent(agent_type)
1004
+
1005
+ # Plan / solo / build agents get dedicated, self-contained instruction
1006
+ # templates (workflow / format / constraints / response style)
1007
+ # instead of the generic description + gate + response-style
1008
+ # assembly below.
1009
+ agent_templates = {
1010
+ "plan": PLAN_AGENT_INSTRUCTIONS,
1011
+ "solo": SOLO_AGENT_INSTRUCTIONS,
1012
+ "build": BUILD_AGENT_INSTRUCTIONS,
1013
+ "explore": EXPLORE_AGENT_INSTRUCTIONS,
1014
+ }
1015
+ template = agent_templates.get(self.current_agent.name)
1016
+ system_parts = [template] if template else [self.current_agent.description]
1017
+
1018
+ if was_plan_mode and not self._is_plan_mode():
1019
+ if not keep_todos:
1020
+ self._todo_manager.clear_todos()
1021
+ self.context.remove_system_by_marker("<!-- PENDING_TODOS -->")
1022
+ self._just_exited_plan_mode = True
1023
+ self._plan_denial_count = 0
1024
+ self._tool_denial_count.clear()
1025
+ self.context.remove_system_by_marker("<!-- PLAN_MODE_DENIED -->")
1026
+
1027
+ if self.current_agent.name not in agent_templates:
1028
+ edit_ask = self.current_agent.should_ask_for_edit()
1029
+ bash_ask = self.current_agent.should_ask_for_bash()
1030
+ if edit_ask or bash_ask:
1031
+ restrictions = []
1032
+ if edit_ask:
1033
+ restrictions.append("- File edits require user approval")
1034
+ if bash_ask:
1035
+ restrictions.append("- Shell commands require user approval")
1036
+ system_parts.append("\n".join(restrictions))
1037
+
1038
+ if self.current_agent.mode == AgentMode.SUBAGENT:
1039
+ system_parts.append(SUBAGENT_CONSTRAINTS)
1040
+
1041
+ if self.current_agent.name not in agent_templates:
1042
+ if self.current_agent.permission_task != AgentPermission.DENY:
1043
+ if self.current_agent.mode.name == "SUBAGENT":
1044
+ pass # subagents don't need plan gate
1045
+ elif self.current_agent.permission_edit == AgentPermission.DENY and self.current_agent.permission_bash == AgentPermission.DENY:
1046
+ pass # read-only mode, no gate needed
1047
+ elif self.current_agent.permission_task == AgentPermission.DENY:
1048
+ pass
1049
+ else:
1050
+ gate = PLAN_GATE_HARD if self.current_agent.mode == AgentMode.PRIMARY and self.current_agent.name == "plan" else PLAN_GATE_SOFT
1051
+ system_parts.append(gate)
1052
+
1053
+ # Response style with CoT reasoning guidance.
1054
+ if self.current_agent.name in agent_templates:
1055
+ pass # response style already embedded in the instruction templates
1056
+ elif self.current_agent.mode == AgentMode.SUBAGENT:
1057
+ system_parts.append(
1058
+ "\n## Response style\n"
1059
+ "- **Every round starts with Chain of Thought reasoning** "
1060
+ "inside `<thinking>`:\n"
1061
+ " 1. Review what the last round's tools produced — any errors?\n"
1062
+ " 2. Assess progress against the task — what remains?\n"
1063
+ " 3. Decide the next action.\n"
1064
+ "- If you need to reason between tool calls, wrap your "
1065
+ "reasoning in `<thinking>...</thinking>`.\n"
1066
+ "- **Intermediate rounds**: visible text is for progress "
1067
+ "updates only. Do NOT announce "
1068
+ '"done" or "complete" unless ALL work is actually finished.\n'
1069
+ "- **FINAL round** (all work done): output a visible summary "
1070
+ "describing what was accomplished, changed, or decided — "
1071
+ "do NOT wrap in `<thinking>`.\n"
1072
+ "- **Asking the user**: when you need user feedback, input, or "
1073
+ "approval, ALWAYS use the AskUser tool to pause and wait for a "
1074
+ "response. Never output a question in visible text and continue "
1075
+ "executing — the session will not pause for your question.\n"
1076
+ )
1077
+ elif self.current_agent.permission_task != AgentPermission.DENY:
1078
+ system_parts.append(
1079
+ "\n## Response style\n"
1080
+ "- **Every round starts with Chain of Thought reasoning** "
1081
+ "inside `<thinking>`:\n"
1082
+ " 1. Review what the last round's tools produced — any errors?\n"
1083
+ " 2. Assess progress against todos — done / pending?\n"
1084
+ " 3. Decide the next single action — direct tool or `Spawn`?\n"
1085
+ "- **Plan-driven execution**:\n"
1086
+ " - Output a step-by-step plan as Markdown first.\n"
1087
+ " - Simple / single-step task → execute directly.\n"
1088
+ " - Complex / multi-step task → `Spawn(agent_type, prompt)` to "
1089
+ "delegate execution.\n"
1090
+ "- If you need to reason between tool calls, wrap your "
1091
+ "reasoning in `<thinking>...</thinking>`.\n"
1092
+ "- **Intermediate rounds**: visible text is for progress "
1093
+ "updates only. Do NOT announce "
1094
+ '"done" or "complete" unless ALL work is actually completed.\n'
1095
+ "- **FINAL round** (all work done): "
1096
+ "output a visible summary describing what was accomplished, "
1097
+ "what was changed, and any important outcomes or limitations — "
1098
+ "do NOT wrap in `<thinking>`.\n"
1099
+ "- **Asking the user**: when you need user feedback, input, or "
1100
+ "approval, ALWAYS use the AskUser tool to pause and wait for a "
1101
+ "response. Never output a question in visible text and continue "
1102
+ "executing — the session will not pause for your question.\n"
1103
+ )
1104
+ else:
1105
+ system_parts.append(
1106
+ "\n## Response style\n"
1107
+ "- **Every round starts with Chain of Thought reasoning** "
1108
+ "inside `<thinking>`:\n"
1109
+ " 1. Review what the last round's tools produced — any errors?\n"
1110
+ " 2. Assess progress — what remains?\n"
1111
+ " 3. Decide the next action.\n"
1112
+ "- If you need to reason between tool calls, wrap your "
1113
+ "reasoning in `<thinking>...</thinking>`.\n"
1114
+ "- **Intermediate rounds**: visible text is for progress "
1115
+ "updates only. Do NOT announce "
1116
+ '"done" or "complete" unless ALL work is actually finished.\n'
1117
+ "- **FINAL round** (all work done): output a visible summary "
1118
+ "describing what was accomplished, changed, or decided — "
1119
+ "do NOT wrap in `<thinking>`.\n"
1120
+ "- **Asking the user**: when you need user feedback, input, or "
1121
+ "approval, ALWAYS use the AskUser tool to pause and wait for a "
1122
+ "response. Never output a question in visible text and continue "
1123
+ "executing — the session will not pause for your question.\n"
1124
+ )
1125
+
1126
+ tagged_content = "<!-- AGENT_CONFIG -->\n" + "\n".join(system_parts)
1127
+ if not self.context.replace_system_section("<!-- AGENT_CONFIG -->", tagged_content):
1128
+ self.context.add_system(tagged_content)
1129
+
1130
+ # Inject TOOL_DESCRIPTIONS as its own marker so it can be independently
1131
+ # stripped when the provider supports native tool schemas.
1132
+ tool_desc = filter_tool_descriptions(
1133
+ allowlist=self.current_agent.tools or None,
1134
+ denylist=self.current_agent.disallowed_tools or None,
1135
+ )
1136
+ tool_tagged = "<!-- TOOL_DESCRIPTIONS -->\n" + tool_desc
1137
+ if not self.context.replace_system_section("<!-- TOOL_DESCRIPTIONS -->", tool_tagged):
1138
+ self.context.add_system(tool_tagged)
1139
+
1140
+ # Re-apply user permission overrides (e.g. "allow always")
1141
+ self._perm_store.apply_to(self.current_agent)
1142
+
1143
+ def _strip_agent_config_tool_descriptions(self) -> None:
1144
+ """Remove the ``<!-- TOOL_DESCRIPTIONS -->`` system section.
1145
+
1146
+ Called after the provider is known, for providers that support
1147
+ native tool schemas and don't need the prose TOOL_DESCRIPTIONS.
1148
+ """
1149
+ self.context.remove_system_by_marker("<!-- TOOL_DESCRIPTIONS -->")
1150
+
1151
+ def get_available_tools(self) -> str:
1152
+ from onecode.agent.agents.types import filter_tool_descriptions
1153
+ return filter_tool_descriptions(
1154
+ allowlist=self.current_agent.tools or None,
1155
+ denylist=self.current_agent.disallowed_tools or None,
1156
+ )
1157
+
1158
+ def _load_skills(self, force: bool = False) -> None:
1159
+ if self._skills_loaded and not force:
1160
+ return
1161
+
1162
+ # Remove previously loaded skill-tagged messages so they don't
1163
+ # accumulate across agent switches or skill re-loads.
1164
+ self.context.remove_system_by_marker("<!-- SKILL:")
1165
+ self.context.remove_system_by_marker("<!-- PROJECT_DOC -->")
1166
+ self.context.remove_system_by_marker("<!-- CDH_PROJECT -->")
1167
+ self.context.remove_system_by_marker("<!-- AIDLC_NUDGE -->")
1168
+ self._skills_loaded = True
1169
+
1170
+ has_cloudbase = False
1171
+ for skill in self._skill_loader.get_enabled():
1172
+ tagged = f"<!-- SKILL:{skill.name} -->\n{skill.content}"
1173
+ self.context.add_system(tagged)
1174
+ if skill.name in ("cloudbase", "tcb"):
1175
+ has_cloudbase = True
1176
+
1177
+ if has_cloudbase:
1178
+ from onecode.mcp.cloudbase import ensure_configured as _ensure_cb
1179
+ needs_reconnect = not _ensure_cb(self._mcp)
1180
+ if not self._mcp.is_connected("cloudbase") or needs_reconnect:
1181
+ try:
1182
+ loop = asyncio.get_event_loop()
1183
+ if loop.is_running():
1184
+ task = asyncio.create_task(self._mcp.connect("cloudbase"))
1185
+ task.add_done_callback(
1186
+ lambda t: logger.info("CloudBase MCP connection task completed")
1187
+ )
1188
+ except Exception as e:
1189
+ logger.warning("Failed to schedule CloudBase MCP connect: %s", e)
1190
+
1191
+ # Project-level single-file doc (AGENTS.md at workspace root)
1192
+ from onecode.agent.project_doc import load_project_doc
1193
+ project_doc = load_project_doc(self._workspace)
1194
+ if project_doc:
1195
+ self.context.add_system(f"<!-- PROJECT_DOC -->\n{project_doc}")
1196
+
1197
+ # Load project .cdh/ state into context
1198
+ cdh_content = CdhProjectLoader.load_for_workspace(self._workspace)
1199
+ if cdh_content:
1200
+ self.context.add_system(f"<!-- CDH_PROJECT -->\n{cdh_content}")
1201
+
1202
+ # AI-DLC contextual nudge (passive triggering for AI-DLC projects)
1203
+ aidlc_nudge = CdhProjectLoader.load_aidlc_nudge(self._workspace)
1204
+ if aidlc_nudge:
1205
+ self.context.add_system(aidlc_nudge)
1206
+
1207
+ def _inject_project_context(self, project_name: str) -> None:
1208
+ if not project_name:
1209
+ return
1210
+ if self._project_context_loaded:
1211
+ return
1212
+
1213
+ self._project_context_loaded = True
1214
+ self._project_config = {}
1215
+
1216
+ context_parts = [
1217
+ f"Project: {project_name}",
1218
+ f"Platform: {self._project_config.get('platform', 'unknown')}",
1219
+ ]
1220
+
1221
+ env_id = self._project_config.get("cloudbase", {}).get("envId", "")
1222
+ if env_id:
1223
+ context_parts.append(f"TCB EnvId: {env_id}")
1224
+
1225
+ self.context.add_system("\n".join(context_parts))
1226
+
1227
+ def _should_retrieve_codebase(self) -> bool:
1228
+ try:
1229
+ cfg = self.app.config.codebase
1230
+ return cfg.enabled and cfg.auto_retrieve
1231
+ except Exception:
1232
+ return False
1233
+
1234
+ async def _get_codebase_engine(self):
1235
+ if self._codebase_engine is not None:
1236
+ return self._codebase_engine
1237
+ try:
1238
+ cfg = self.app.config.codebase
1239
+ if not cfg.enabled:
1240
+ return None
1241
+ from onecode.codebase import CodebaseEngine
1242
+ self._codebase_engine = CodebaseEngine(self._project_dir, cfg)
1243
+ await self._codebase_engine.ensure_indexed()
1244
+ return self._codebase_engine
1245
+ except Exception as e:
1246
+ logger.warning("Failed to init codebase engine: %s", e)
1247
+ return None
1248
+
1249
+ async def chat(self, user_input: str) -> str:
1250
+ await asyncio.to_thread(self._load_skills)
1251
+
1252
+ project_name = getattr(self.app, "current_project", None) or ""
1253
+ if project_name:
1254
+ self._inject_project_context(project_name)
1255
+
1256
+ self.context.add_user(user_input)
1257
+
1258
+ # Proactive AI-DLC intent analysis for AI-DLC projects
1259
+ from cdh.project_loader import CdhProjectLoader
1260
+ if CdhProjectLoader.is_aidlc_project(self._workspace):
1261
+ intent_analysis = CdhProjectLoader.analyze_user_intent(user_input, self._workspace)
1262
+ if intent_analysis and intent_analysis.get("confidence", 0) >= 0.6:
1263
+ analysis_msg = (
1264
+ f"\n<!-- AI-DLC Intent Analysis (auto-injected) -->\n"
1265
+ f"User intent detected: {intent_analysis['suggestion']}\n"
1266
+ f"Reasons: {', '.join(intent_analysis.get('reasons', []))}\n"
1267
+ f"If this is an AI-DLC task, consider starting with phase: "
1268
+ f"`{intent_analysis['phases'][0]}`\n"
1269
+ )
1270
+ self.context.add_system(analysis_msg)
1271
+
1272
+ self.context.set_model(self.app.current_model)
1273
+ if self.context.should_compact():
1274
+ level = self.context.compact()
1275
+ if level not in ("none",):
1276
+ logger.info("Context compaction applied: %s", level)
1277
+
1278
+ provider_cls = ProviderRegistry.get(self.app.current_provider)
1279
+ if not provider_cls:
1280
+ return f"Provider '{self.app.current_provider}' not available."
1281
+
1282
+ config = self.app.config.providers.get(self.app.current_provider)
1283
+ if config is None:
1284
+ return f"Provider '{self.app.current_provider}' not configured."
1285
+
1286
+ provider = provider_cls(
1287
+ api_key=config.api_key or "",
1288
+ endpoint=config.endpoint or None,
1289
+ )
1290
+
1291
+ response = await provider.chat(
1292
+ self.context.get_context(),
1293
+ model=self.app.current_model,
1294
+ )
1295
+
1296
+ self.context.add_assistant(response.content)
1297
+ self.iterations += 1
1298
+ self.total_tokens += response.usage.get("total_tokens", 0)
1299
+ return response.content
1300
+
1301
+ def _parse_tool_calls(self, text: str) -> list[dict]:
1302
+ calls = []
1303
+ for match in TOOL_CALL_RE.finditer(text):
1304
+ name = match.group(1)
1305
+ call_id = match.group(2)
1306
+ raw_input = match.group(3).strip()
1307
+ try:
1308
+ parsed_input = json.loads(raw_input) if raw_input else {}
1309
+ except json.JSONDecodeError:
1310
+ parsed_input = {"raw": raw_input}
1311
+ calls.append({"name": name, "id": call_id, "input": parsed_input})
1312
+ return calls
1313
+
1314
+ def _validate_edit(self, path: str) -> str | None:
1315
+ try:
1316
+ content = self.file_ops.read(path, 0, 0)
1317
+ if content and "error" not in str(content).lower():
1318
+ return None
1319
+ return f"Warning: File may not have been written correctly: {path}"
1320
+ except Exception as e:
1321
+ return f"Warning: Could not verify file: {e}"
1322
+
1323
+ def _tool_category(self, name: str) -> str:
1324
+ """Map tool name to category for structured display."""
1325
+ from onecode.models.messages import get_tool_category
1326
+ return get_tool_category(name).value
1327
+
1328
+ _TOOL_NAME_TO_PERM_KEY: dict[str, str] = {
1329
+ "Read": "read",
1330
+ "Write": "edit",
1331
+ "Edit": "edit",
1332
+ "Insert": "edit",
1333
+ "UndoEdit": "edit",
1334
+ "ApplyPatch": "edit",
1335
+ "Bash": "bash",
1336
+ "WebFetch": "webfetch",
1337
+ "WebSearch": "websearch",
1338
+ "Glob": "glob",
1339
+ "Grep": "grep",
1340
+ "List": "list",
1341
+ "Spawn": "task",
1342
+ "Agent": "task",
1343
+ "Skill": "skill",
1344
+ "codebase_search": "read",
1345
+ "TodoCreate": "todowrite",
1346
+ "TodoUpdate": "todowrite",
1347
+ "TodoStop": "todowrite",
1348
+ "TodoClear": "todowrite",
1349
+ "TodoGet": "todoread",
1350
+ "TodoList": "todoread",
1351
+ "TodoOutput": "todoread",
1352
+ }
1353
+
1354
+ def _check_tool_permission(self, name: str, inp: dict) -> str | None:
1355
+ """Unified agent-level permission check for all tools."""
1356
+ from onecode.agent.agents.types import AgentPermission
1357
+ # Enforce agent allow/deny lists at runtime. This is the runtime
1358
+ # safety net behind filter_tool_descriptions(): even if the LLM
1359
+ # still emits a tool the system prompt hid (e.g. TodoCreate in a
1360
+ # subagent, after context compaction re-introduced the name), the
1361
+ # call is denied here rather than leaking through.
1362
+ if not self.current_agent.tool_allowed(name):
1363
+ return json.dumps({"success": False, "error": f"{name} denied (disallowed for {self.current_agent.name})"})
1364
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
1365
+ tools_config = self.current_agent.get_tools_config()
1366
+ if perm_key is None:
1367
+ # Fallback: if tool is not in the explicit mapping, check whether
1368
+ # it is read-only. If not, treat it as an edit-tool and use the
1369
+ # edit permission. This catches tools like ConfigWrite, CronCreate,
1370
+ # Worktree, MCPTool, etc. that would otherwise bypass plan mode.
1371
+ spec = self._tool_registry.get(name)
1372
+ if spec and not spec.spec().is_read_only:
1373
+ perm = tools_config.get("edit", AgentPermission.ALLOW)
1374
+ if perm == AgentPermission.DENY:
1375
+ return json.dumps({"success": False, "error": f"{name} denied (read-only mode)"})
1376
+ if perm == AgentPermission.ASK:
1377
+ return json.dumps({"success": False, "error": f"{name} requires approval", "requires_approval": True})
1378
+ return None
1379
+ perm = tools_config.get(perm_key, AgentPermission.ALLOW)
1380
+ if perm == AgentPermission.DENY:
1381
+ return json.dumps({"success": False, "error": f"{name} denied"})
1382
+ if perm == AgentPermission.ASK:
1383
+ return json.dumps({"success": False, "error": f"{name} requires approval", "requires_approval": True})
1384
+ if name == "Spawn" and self._is_plan_mode():
1385
+ agent_type = inp.get("agent_type", "general")
1386
+ if agent_type in ("build", "solo", "general"):
1387
+ return json.dumps({
1388
+ "success": False,
1389
+ "error": (
1390
+ f"Spawn({agent_type}) denied in plan mode. "
1391
+ "Use explore or scout for research."
1392
+ ),
1393
+ })
1394
+ return None
1395
+
1396
+ def hidden_tool_names(self) -> set[str]:
1397
+ """Tools that must NOT be advertised to the LLM for the current agent.
1398
+
1399
+ Mirrors ``_check_tool_permission`` exactly: a tool is hidden when it
1400
+ is disallowed by the agent's allow/deny lists, or when its permission
1401
+ key resolves to hard DENY. Unmapped non-read-only tools fall back to
1402
+ the edit permission, matching the runtime fallback.
1403
+ """
1404
+ from onecode.agent.agents.types import AgentPermission
1405
+ agent = self.current_agent
1406
+ tools_config = agent.get_tools_config()
1407
+ hidden: set[str] = set()
1408
+ for spec in self._tool_registry.list_specs():
1409
+ name = spec.name
1410
+ if not agent.tool_allowed(name):
1411
+ hidden.add(name)
1412
+ continue
1413
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
1414
+ if perm_key is not None:
1415
+ if tools_config.get(perm_key, AgentPermission.ALLOW) == AgentPermission.DENY:
1416
+ hidden.add(name)
1417
+ elif not spec.is_read_only:
1418
+ if tools_config.get("edit", AgentPermission.ALLOW) == AgentPermission.DENY:
1419
+ hidden.add(name)
1420
+ return hidden
1421
+
1422
+ def _register_tool_denial(self, name: str) -> None:
1423
+ """Track repeated permission denials for one tool and inject an
1424
+ escalating hint so the model stops retrying calls that can never
1425
+ succeed in the current mode."""
1426
+ count = self._tool_denial_count.get(name, 0) + 1
1427
+ self._tool_denial_count[name] = count
1428
+ marker = f"<!-- TOOL_DENIED_{name} -->"
1429
+ self.context.remove_system_by_marker(marker)
1430
+ if count >= 3:
1431
+ self.context.insert_system_before_non_system(
1432
+ f"{marker}\n"
1433
+ f"## Repeatedly blocked tool: {name}\n"
1434
+ f"You have tried to use `{name}` {count} times and it was "
1435
+ f"denied every time. It will NEVER succeed in the current "
1436
+ f"agent/mode. Stop calling it. Re-read the plan and the "
1437
+ f"available tool list, adjust your approach, or use AskUser "
1438
+ f"to ask the user how to proceed."
1439
+ )
1440
+ else:
1441
+ self.context.insert_system_before_non_system(
1442
+ f"{marker}\n"
1443
+ f"You attempted to use `{name}` but it was denied by the "
1444
+ f"current agent's permissions. Do NOT retry the same call — "
1445
+ f"adjust your approach or ask the user via AskUser."
1446
+ )
1447
+
1448
+ def _save_plan_document(self, plan_text: str) -> str | None:
1449
+ """Persist the submitted plan to ``.cdh/plans/plan-{id}.md``.
1450
+
1451
+ Returns the absolute path, or None on failure. The document survives
1452
+ context compaction and is re-read by the execution agent at handoff.
1453
+ """
1454
+ try:
1455
+ from onecode.agent.cdh_loader import CdhProjectLoader
1456
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
1457
+ if cdh_dir is None:
1458
+ cdh_dir = self._project_dir / CdhProjectLoader.CDH_DIRNAME
1459
+ plans_dir = cdh_dir / "plans"
1460
+ plans_dir.mkdir(parents=True, exist_ok=True)
1461
+ path = plans_dir / f"plan-{int(time.time())}.md"
1462
+ path.write_text(plan_text or "(empty plan)", "utf-8")
1463
+ return str(path)
1464
+ except Exception as e:
1465
+ logger.warning("Failed to save plan document: %s", e)
1466
+ return None
1467
+
1468
+ def _plan_handoff(self, plan_path: str | None) -> bool:
1469
+ """Switch from the plan agent to the execution agent after approval.
1470
+
1471
+ Keeps the approved task list (todos) and injects the approved plan
1472
+ document into the execution agent's system context so it survives
1473
+ compaction and stays the single source of truth.
1474
+
1475
+ Returns True if handoff was successful, False otherwise.
1476
+ """
1477
+ logger.info("_plan_handoff called: plan_path=%s, current_agent=%s",
1478
+ plan_path, self.current_agent.name)
1479
+ try:
1480
+ if self.current_agent.name != "plan":
1481
+ logger.warning("_plan_handoff skipped: not in plan mode (current=%s)",
1482
+ self.current_agent.name)
1483
+ return False
1484
+ self.set_agent("solo", keep_todos=True)
1485
+ logger.info("_plan_handoff: agent switched to solo, context updated")
1486
+ plan_text = ""
1487
+ if plan_path:
1488
+ try:
1489
+ plan_text = Path(plan_path).read_text("utf-8")
1490
+ except Exception:
1491
+ plan_text = ""
1492
+ todos = "\n".join(
1493
+ f"- [{t['id']}] {t['subject']}"
1494
+ for t in self._todo_manager.list_todos()
1495
+ )
1496
+ section = (
1497
+ "<!-- APPROVED_PLAN -->\n"
1498
+ "## Approved plan (execution mode)\n"
1499
+ f"Plan document: {plan_path}\n\n"
1500
+ f"{plan_text}\n\n"
1501
+ "### Task list\n"
1502
+ f"{todos or '(empty)'}\n\n"
1503
+ "Execute the approved plan: work through the task list and "
1504
+ "mark each todo completed via TodoUpdate as you finish it."
1505
+ )
1506
+ if not self.context.replace_system_section("<!-- APPROVED_PLAN -->", section):
1507
+ self.context.insert_system_before_non_system(section)
1508
+ except Exception as e:
1509
+ logger.error("Plan handoff failed: %s", e, exc_info=True)
1510
+ return False
1511
+ return True
1512
+
1513
+ def _react_phase_text(self, round_no: int) -> str:
1514
+ """Per-round CoT guidance header; the plan agent has its own stages."""
1515
+ if self.current_agent and self.current_agent.name == "plan":
1516
+ return f"<!-- REACT_PHASE -->\n## Round {round_no} — 思考 → 规划 → 审批\n"
1517
+ return f"<!-- REACT_PHASE -->\n## Round {round_no} — 思考 → Todo → 行动\n"
1518
+
1519
+ async def _execute_tool(self, tool_call: dict) -> dict:
1520
+ from onecode.agent.tools.registry import ToolCall as RegistryToolCall
1521
+ name = tool_call["name"]
1522
+ tid = tool_call["id"]
1523
+ inp = tool_call["input"]
1524
+ category = self._tool_category(name)
1525
+ base = {"tool_use_id": tid, "is_error": False, "category": category}
1526
+ try:
1527
+ denied = self._check_tool_permission(name, inp)
1528
+ if denied:
1529
+ self._register_tool_denial(name)
1530
+ return {**base, "content": denied, "is_error": True}
1531
+
1532
+ call = RegistryToolCall(name=name, input=inp, tool_use_id=tid)
1533
+ result = await self._tool_registry.dispatch_async(
1534
+ call, cancel_check=lambda: self._cancelled,
1535
+ )
1536
+
1537
+ # Handle SendMessage tracking
1538
+ if name == "SendMessage":
1539
+ msg_output = result.output if isinstance(result.output, dict) else {}
1540
+ self._last_user_msg = msg_output.get("message", "")
1541
+ return {**base, "content": json.dumps(result.output), "is_error": result.is_error}
1542
+
1543
+ # Format output for backward compatibility
1544
+ output = self._format_tool_output(result)
1545
+ return {**base, "content": output, "is_error": result.is_error}
1546
+ except Exception as e:
1547
+ logger.exception(f"Tool execution error: {e}")
1548
+ return {**base, "content": f"Error: {safe_error_msg(e)}", "is_error": True}
1549
+
1550
+ def _format_tool_output(self, result: RegistryToolResult) -> str: # noqa: F821
1551
+ import json
1552
+ output = result.output
1553
+ if isinstance(output, str):
1554
+ return output
1555
+ try:
1556
+ return json.dumps(output)
1557
+ except (TypeError, ValueError):
1558
+ return str(output)
1559
+
1560
+ def cancel(self):
1561
+ """Cancel the current chat_stream turn and all in-flight subagents."""
1562
+ self._cancelled = True
1563
+ for child in self._child_engines:
1564
+ child._cancelled = True
1565
+ self._mcp.cancel_all()
1566
+
1567
+ async def shutdown(self):
1568
+ """Release OS resources held by this engine (LSP, MCP, cron)."""
1569
+ self._cancelled = True
1570
+ for child in self._child_engines:
1571
+ child._cancelled = True
1572
+ self._lsp_tool.stop_all()
1573
+ await self._mcp.disconnect_all()
1574
+ self._cron_scheduler.stop_loop()
1575
+
1576
+ def __del__(self):
1577
+ """Fallback cleanup on garbage collection — wrapped in try/except
1578
+ to avoid crashing during interpreter teardown when modules may
1579
+ already be collected."""
1580
+ try:
1581
+ self._lsp_tool.stop_all()
1582
+ except Exception:
1583
+ pass
1584
+ try:
1585
+ self._cron_scheduler.stop_loop()
1586
+ except Exception:
1587
+ pass
1588
+
1589
+ async def chat_stream(self, user_input: str | list[dict]) -> AsyncIterator[StreamEvent | str]:
1590
+ await asyncio.to_thread(self._load_skills)
1591
+ self._ctx_err_retries = 0
1592
+ self._verification_fail_count = 0
1593
+ preview = user_input[:100] if isinstance(user_input, str) else f"[{len(user_input)} content blocks]"
1594
+ logger.info(f"chat_stream() called with user_input='{preview}'")
1595
+
1596
+ project_name = getattr(self.app, "current_project", None) or ""
1597
+ if project_name:
1598
+ was_context_loaded = self._project_context_loaded
1599
+ self._inject_project_context(project_name)
1600
+ if not was_context_loaded:
1601
+ yield StreamEvent.text_delta(
1602
+ f"\n📋 项目: {project_name}\n继续开发中...\n\n"
1603
+ )
1604
+ else:
1605
+ pass
1606
+
1607
+ # Emit initial plan so the TUI Plan widget is mounted (or refreshed
1608
+ # to the current snapshot) at the start of every turn. This is
1609
+ # required because the Plan widget only renders when an ``entries``
1610
+ # value arrives — without this, the widget never appears until the
1611
+ # agent happens to call a task tool.
1612
+ for event in self._emit_plan_update():
1613
+ yield event
1614
+ self._plan_dirty = False
1615
+
1616
+ # ── Codebase auto-retrieval ──
1617
+ # Skipped entirely for subagent engines (see _disable_retrieval)
1618
+ # to avoid the 2.3s+ indexer loop and the per-chunk embedding httpx
1619
+ # calls that block the event loop and freeze streaming/cancel.
1620
+ # Also skipped when _ctx_crisis is set to prevent "compact →
1621
+ # re-inject → compact" loops after a ContextLengthError recovery.
1622
+ if (
1623
+ isinstance(user_input, str)
1624
+ and self._should_retrieve_codebase()
1625
+ and not self._disable_retrieval
1626
+ and not self._ctx_crisis
1627
+ ):
1628
+ try:
1629
+ engine = await self._get_codebase_engine()
1630
+ if engine:
1631
+ chunks = await engine.retrieve(user_input)
1632
+ ctx_text = engine.format_context(
1633
+ chunks, max_tokens=self.app.config.codebase.max_chunk_tokens
1634
+ )
1635
+ if ctx_text:
1636
+ tagged = f"<!-- CODEBASE -->\n{ctx_text}"
1637
+ if not self.context.replace_system_section("<!-- CODEBASE -->", tagged):
1638
+ self.context.add_system(tagged)
1639
+ else:
1640
+ self.context.remove_system_by_marker("<!-- CODEBASE -->")
1641
+ except Exception as e:
1642
+ logger.warning("Codebase retrieval failed: %s", e)
1643
+
1644
+ # ── Long-term memory recall ──
1645
+ if (
1646
+ isinstance(user_input, str)
1647
+ and self._session
1648
+ and self.app.config.memory.enabled
1649
+ and self.app.config.memory.auto_recall
1650
+ and not self._disable_retrieval
1651
+ and not self._ctx_crisis
1652
+ ):
1653
+ try:
1654
+ top_k = self.app.config.memory.top_k
1655
+ results = self._memory.search_memories(user_input, top_k=top_k)
1656
+ if results:
1657
+ lines = ["## Relevant past memories"]
1658
+ for r in results:
1659
+ lines.append(r.content[:300])
1660
+ tagged = "<!-- MEMORY -->\n" + "\n".join(lines)
1661
+ if not self.context.replace_system_section("<!-- MEMORY -->", tagged):
1662
+ self.context.add_system(tagged)
1663
+ else:
1664
+ self.context.remove_system_by_marker("<!-- MEMORY -->")
1665
+ except Exception as e:
1666
+ logger.warning("Memory recall failed: %s", e)
1667
+
1668
+ # ── REACT_PHASE must be injected BEFORE add_user so system
1669
+ # messages stay at the top of the message list. On subsequent
1670
+ # chat_stream calls replace_system_section updates it in-place.
1671
+ cot_phase_init = self._react_phase_text(1)
1672
+ if not self.context.replace_system_section("<!-- REACT_PHASE -->", cot_phase_init):
1673
+ self.context.add_system(cot_phase_init)
1674
+
1675
+ self.context.add_user(user_input)
1676
+
1677
+ # Plan gate: activate based on agent mode, not content heuristics.
1678
+ # Gate fires reactively when agent tries execution tools without a plan.
1679
+ self._plan_gate_mode = self._resolve_plan_gate_mode()
1680
+
1681
+ self.context.set_model(self.app.current_model)
1682
+ if self.context.should_compact():
1683
+ level = self.context.compact()
1684
+ if level not in ("none",):
1685
+ logger.info("Context compaction at turn start: %s", level)
1686
+
1687
+ provider_name = self.app.current_provider
1688
+ model_name = self.app.current_model
1689
+ logger.info(f"Using provider='{provider_name}', model='{model_name}'")
1690
+ logger.info(
1691
+ "chat_stream engine state: project=%s agent=%s todos=%d context_msgs=%d ctx_tokens=%d",
1692
+ project_name or "(none)",
1693
+ self.current_agent.name if self.current_agent else "None",
1694
+ len(self._todo_manager.list_todos()),
1695
+ len(self.context.messages),
1696
+ self.context._token_count,
1697
+ )
1698
+
1699
+ provider_cls = ProviderRegistry.get(provider_name)
1700
+ if not provider_cls:
1701
+ available = list(ProviderRegistry._registry.keys()) if hasattr(ProviderRegistry, '_registry') else "unknown"
1702
+ error_msg = f"Provider '{provider_name}' not available. Available: {available}"
1703
+ logger.error(error_msg)
1704
+ yield StreamEvent.error(error_msg)
1705
+ return
1706
+
1707
+ config = self.app.config.providers.get(provider_name)
1708
+ if config is None:
1709
+ available = list(self.app.config.providers.keys())
1710
+ error_msg = f"Provider '{provider_name}' not configured. Available providers: {available}"
1711
+ logger.error(error_msg)
1712
+ yield StreamEvent.error(error_msg)
1713
+ return
1714
+
1715
+ logger.info(f"Creating provider instance: {provider_cls.__name__}")
1716
+ provider_kwargs = dict(api_key=config.api_key or "", endpoint=config.endpoint or None)
1717
+ provider = provider_cls(**provider_kwargs)
1718
+
1719
+ # If the provider supports native tool schemas (OpenAI/Anthropic/DeepSeek
1720
+ # etc.), strip the prose TOOL_DESCRIPTIONS from system context to save
1721
+ # tokens. The structured ``tools`` kwarg provides the same info.
1722
+ if not hasattr(provider, 'supports_native_tools') or provider.supports_native_tools():
1723
+ self._strip_agent_config_tool_descriptions()
1724
+
1725
+ # Reset per-turn usage tracking
1726
+ self._turn_usages = []
1727
+
1728
+ # Guard against concurrent chat_stream calls: if the engine was
1729
+ # already cancelled before the adapter called us, honour that.
1730
+ # Do NOT blindly reset _cancelled — a caller may have called
1731
+ # cancel() between turn boundary.
1732
+ if self._cancelled:
1733
+ logger.warning("chat_stream: engine already cancelled, aborting")
1734
+ yield StreamEvent.text_delta("\n\n*Cancelled*\n\n")
1735
+ return
1736
+
1737
+ # ── Agent loop: 思考 → Todo → 行动 (per-Round) ──
1738
+ is_anthropic = provider.is_anthropic_style()
1739
+ max_turns = self.current_agent.max_turns or 5000
1740
+ hard_limit = max_turns
1741
+ hard_limit_extensions = 0
1742
+ max_hard_limit_extensions = float('inf') if (
1743
+ self.current_agent.name == "solo" or self._has_pending_todos()
1744
+ ) else 5
1745
+
1746
+ def _infinite_turns():
1747
+ t = 0
1748
+ while True:
1749
+ yield t
1750
+ t += 1
1751
+
1752
+ for turn in _infinite_turns():
1753
+ # Absolute ceiling: never exceed max_iterations (safety net)
1754
+ absolute_ceiling = self.app.config.agent.max_iterations
1755
+ if turn >= absolute_ceiling:
1756
+ if not self._has_pending_todos():
1757
+ logger.warning(
1758
+ "Absolute ceiling (%d) reached at turn %d — stopping",
1759
+ absolute_ceiling, turn,
1760
+ )
1761
+ break
1762
+ # Dynamic extension: if hard_limit reached but agent is still
1763
+ # actively working (pending todos, or just called tools), keep going.
1764
+ if turn >= hard_limit:
1765
+ if hard_limit_extensions < max_hard_limit_extensions and (
1766
+ self._has_pending_todos() or self._empty_tool_turns == 0
1767
+ ):
1768
+ pending = sum(1 for t in self._todo_manager.list_todos()
1769
+ if t.get("status") in ("pending", "in_progress"))
1770
+ hard_limit_extensions += 1
1771
+ logger.info(
1772
+ "hard_limit (%d) reached at turn %d with %d pending todos — "
1773
+ "extending dynamically (extension %d/%d)",
1774
+ hard_limit, turn, pending,
1775
+ hard_limit_extensions, max_hard_limit_extensions,
1776
+ )
1777
+ hard_limit += 20
1778
+ lines = [
1779
+ "<!-- FORCE_CONTINUE -->",
1780
+ f"## {turn} rounds completed, {pending} todos still pending",
1781
+ "Continue executing the next todo. Do NOT stop or summarise.",
1782
+ ]
1783
+ self._inject_nudge(lines)
1784
+ else:
1785
+ if self._has_pending_todos():
1786
+ pending = sum(1 for t in self._todo_manager.list_todos()
1787
+ if t.get("status") in ("pending", "in_progress"))
1788
+ logger.warning(
1789
+ "hard_limit extensions exhausted at turn %d with %d pending todos — pausing",
1790
+ turn, pending,
1791
+ )
1792
+ yield StreamEvent.text_delta(
1793
+ f"\n\n*Pausing: hard_limit extensions exhausted with {pending} pending todos. "
1794
+ "Type 'continue' to proceed or 'stop' to end.*\n\n"
1795
+ )
1796
+ return
1797
+ if self._cancelled:
1798
+ self._cleanup_injected_messages()
1799
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1800
+ return
1801
+
1802
+ # ── Thought Phase: update CoT reasoning guidance for this turn ──
1803
+ # Cleanup stale reminders that may have accumulated
1804
+ self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
1805
+ self.context.remove_system_by_marker("<!-- PLAN_REMINDER -->")
1806
+ # Inject (or refresh) a nudge that prioritizes any unfinished todos
1807
+ # from this session. The marker is replaced in-place so the context
1808
+ # stays bounded — the agent always sees the current open list.
1809
+ self._refresh_pending_todos_nudge()
1810
+ if turn > 0:
1811
+ self.context.replace_system_section(
1812
+ "<!-- REACT_PHASE -->",
1813
+ self._react_phase_text(turn + 1),
1814
+ )
1815
+
1816
+ self._pending_thinking_blocks = []
1817
+
1818
+ self.context.set_model(self.app.current_model)
1819
+ if self.context.should_compact():
1820
+ level = self.context.compact()
1821
+ if level not in ("none",):
1822
+ logger.info("Context compaction at turn %d: %s", turn, level)
1823
+ elif turn > 0 and turn % 3 == 0:
1824
+ self.context._update_token_count()
1825
+ logger.debug("Periodic token recalibration at turn %d: ~%d tokens",
1826
+ turn + 1, self.context._token_count)
1827
+
1828
+ turn_retries = 0
1829
+ turn_usage: dict[str, int] = {}
1830
+ response_text = ""
1831
+ tool_uses: list[dict] = []
1832
+
1833
+ try:
1834
+ context_messages = self.context.get_context()
1835
+ logger.info(f"Calling provider.chat_stream_response (turn {turn+1})")
1836
+ self._streaming_used = False
1837
+ if self.on_text_chunk is not None:
1838
+ _original_cb = self.on_text_chunk
1839
+ def _stream_wrapper(text: str) -> None:
1840
+ self._streaming_used = True
1841
+ if self._cancelled:
1842
+ raise TurnCancelledError()
1843
+ _original_cb(text)
1844
+ stream_cb = _stream_wrapper
1845
+ else:
1846
+ stream_cb = None
1847
+ chat_response = await provider.chat_stream_response(
1848
+ context_messages,
1849
+ model=model_name,
1850
+ on_text_chunk=stream_cb,
1851
+ on_tool_call_delta=self.on_tool_call_delta,
1852
+ cancel_check=lambda: self._cancelled,
1853
+ tools=self._tool_registry.make_openai_schemas(
1854
+ exclude=self.hidden_tool_names()
1855
+ ),
1856
+ )
1857
+ response_text = chat_response.content
1858
+ tool_uses = chat_response.tool_uses
1859
+ if not tool_uses and (
1860
+ "<minimax:tool_call>" in response_text
1861
+ or _LEGACY_OPEN in response_text
1862
+ ):
1863
+ parsed_uses, response_text, self._tool_id_counter = (
1864
+ _extract_minimax_tool_uses(
1865
+ response_text, id_start=self._tool_id_counter
1866
+ )
1867
+ )
1868
+ legacy_uses, response_text, self._tool_id_counter = (
1869
+ _extract_legacy_tool_uses(
1870
+ response_text, id_start=self._tool_id_counter
1871
+ )
1872
+ )
1873
+ tool_uses = parsed_uses + legacy_uses
1874
+ if tool_uses:
1875
+ logger.info(
1876
+ "Extracted %d text tool uses (%d minimax, %d legacy) from response",
1877
+ len(tool_uses), len(parsed_uses), len(legacy_uses),
1878
+ )
1879
+ if chat_response.usage:
1880
+ turn_usage = chat_response.usage
1881
+ except NotImplementedError:
1882
+ logger.info("chat_stream_response not supported, falling back to non-streaming chat()")
1883
+ try:
1884
+ context_messages = self.context.get_context()
1885
+ model_response = await provider.chat(
1886
+ context_messages,
1887
+ model=model_name,
1888
+ tools=self._tool_registry.make_openai_schemas(
1889
+ exclude=self.hidden_tool_names()
1890
+ ),
1891
+ )
1892
+ response_text = model_response.get_text()
1893
+ tool_uses = [
1894
+ {"id": tu.id, "name": tu.name, "input": tu.input}
1895
+ for cb in model_response.content
1896
+ if cb.type == ContentBlockType.TOOL_USE and cb.tool_use
1897
+ for tu in [cb.tool_use]
1898
+ ]
1899
+ if not tool_uses and (
1900
+ "<minimax:tool_call>" in response_text
1901
+ or _LEGACY_OPEN in response_text
1902
+ ):
1903
+ parsed_uses, response_text, self._tool_id_counter = (
1904
+ _extract_minimax_tool_uses(
1905
+ response_text, id_start=self._tool_id_counter
1906
+ )
1907
+ )
1908
+ legacy_uses, response_text, self._tool_id_counter = (
1909
+ _extract_legacy_tool_uses(
1910
+ response_text, id_start=self._tool_id_counter
1911
+ )
1912
+ )
1913
+ tool_uses = parsed_uses + legacy_uses
1914
+ if tool_uses:
1915
+ logger.info(
1916
+ "Extracted %d text tool uses (%d minimax, %d legacy) from chat() fallback",
1917
+ len(tool_uses), len(parsed_uses), len(legacy_uses),
1918
+ )
1919
+ if model_response.usage:
1920
+ turn_usage = model_response.usage
1921
+ except TransientProviderError:
1922
+ raise # propagate to outer TransientProviderError handler for retry
1923
+ except (KeyboardInterrupt, asyncio.CancelledError):
1924
+ self._cleanup_injected_messages()
1925
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1926
+ return
1927
+ except Exception as e:
1928
+ logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
1929
+ if self._has_pending_todos():
1930
+ pending = sum(1 for t in self._todo_manager.list_todos()
1931
+ if t.get("status") in ("pending", "in_progress"))
1932
+ yield StreamEvent.text_delta(
1933
+ f"\n\n*Error: {safe_error_msg(e)}, {pending} todos remaining. Attempting to continue...*\n\n"
1934
+ )
1935
+ continue
1936
+ yield StreamEvent.error(safe_error_msg(e))
1937
+ break
1938
+ except TurnCancelledError:
1939
+ self._cleanup_injected_messages()
1940
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1941
+ return
1942
+ except ContextLengthError as e:
1943
+ ctx_retries = getattr(self, '_ctx_err_retries', 0) + 1
1944
+ self._ctx_err_retries = ctx_retries
1945
+ ctx_msgs_before = len(self.context.messages)
1946
+ ctx_tokens_before = self.context._token_count
1947
+ logger.warning(
1948
+ "ContextLengthError on turn %d (ctx_attempt %d): %s\n"
1949
+ " context_msgs=%d ctx_tokens=%d — forcing compaction",
1950
+ turn + 1, ctx_retries, e,
1951
+ ctx_msgs_before, ctx_tokens_before,
1952
+ )
1953
+ self._turn_events.append({
1954
+ "turn": turn + 1,
1955
+ "kind": "context_overflow_retry",
1956
+ "ctx_retries": ctx_retries,
1957
+ "message": str(e),
1958
+ })
1959
+ if ctx_retries >= 3:
1960
+ logger.error(
1961
+ "ContextLengthError exhausted %d compaction retries "
1962
+ "on turn %d — giving up", ctx_retries, turn + 1,
1963
+ )
1964
+ self._turn_events.append({
1965
+ "turn": turn + 1,
1966
+ "kind": "context_overflow_exhausted",
1967
+ "ctx_retries": ctx_retries,
1968
+ "message": str(e),
1969
+ })
1970
+ yield StreamEvent.error(
1971
+ f"Context length exceeded (turn {turn+1}), "
1972
+ f"compaction exhausted after {ctx_retries} attempts"
1973
+ )
1974
+ if self._has_pending_todos():
1975
+ pending = sum(1 for t in self._todo_manager.list_todos()
1976
+ if t.get("status") in ("pending", "in_progress"))
1977
+ yield StreamEvent.text_delta(
1978
+ f"\n\n*Context overflow with {pending} pending todos. Pausing for user input...*\n\n"
1979
+ )
1980
+ return
1981
+ break
1982
+ level = self.context.compact()
1983
+ ctx_tokens_after = self.context._token_count
1984
+ tokens_unchanged = ctx_tokens_after >= ctx_tokens_before
1985
+ if tokens_unchanged and ctx_retries >= 2:
1986
+ self._ctx_crisis = True
1987
+ for marker in ("<!-- SKILL:", "<!-- AI-DLC:",
1988
+ "<!-- PROJECT_DOC -->",
1989
+ "<!-- CODEBASE -->", "<!-- MEMORY -->",
1990
+ "<!-- CDH_PROJECT -->", "<!-- COMPACT_SUMMARY -->"):
1991
+ removed = self.context.remove_system_by_marker(marker)
1992
+ if removed:
1993
+ logger.info(
1994
+ "ContextLengthError crisis: removed %d system "
1995
+ "msg(s) with marker %s", removed, marker,
1996
+ )
1997
+ level = self.context.compact()
1998
+ ctx_tokens_after = self.context._token_count
1999
+ if ctx_tokens_after >= ctx_tokens_before and self._ctx_crisis:
2000
+ for m in self.context.messages:
2001
+ if m.role == "system" and isinstance(m.content, str) and len(m.content) > 2000:
2002
+ m.content = m.content[:2000] + "\n... [emergency truncation]"
2003
+ self.context._update_token_count()
2004
+ ctx_tokens_after = self.context._token_count
2005
+ if ctx_tokens_after < ctx_tokens_before:
2006
+ logger.info(
2007
+ "ContextLengthError recovery (ctx_attempt %d): compact "
2008
+ "level=%s tokens %d→%d, retrying turn %d",
2009
+ ctx_retries, level, ctx_tokens_before,
2010
+ ctx_tokens_after, turn + 1,
2011
+ )
2012
+ yield StreamEvent.text_delta(
2013
+ "\n\n*Context window exceeded, compressing and retrying…*\n\n"
2014
+ )
2015
+ continue
2016
+ logger.error(
2017
+ "ContextLengthError recovery failed (ctx_attempt %d): "
2018
+ "level=%s tokens before=%d after=%d",
2019
+ ctx_retries, level, ctx_tokens_before, ctx_tokens_after,
2020
+ )
2021
+ yield StreamEvent.error(
2022
+ f"Context length exceeded (turn {turn+1}), "
2023
+ f"compaction did not reduce tokens"
2024
+ )
2025
+ break
2026
+ except TransientProviderError as e:
2027
+ turn_retries += 1
2028
+ max_retries = self._cfg.retry.max_attempts
2029
+ backoff_max = self._cfg.retry.backoff_max
2030
+ backoff_jitter = self._cfg.retry.backoff_jitter
2031
+ if turn_retries <= max_retries:
2032
+ if self._cancelled:
2033
+ self._cleanup_injected_messages()
2034
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2035
+ return
2036
+ if e.retry_after is not None:
2037
+ delay = max(0.0, e.retry_after)
2038
+ else:
2039
+ delay = min(2 ** (turn_retries - 1), backoff_max)
2040
+ jitter = random.uniform(-backoff_jitter, backoff_jitter) if backoff_jitter > 0 else 0.0
2041
+ delay = max(0.1, delay + jitter)
2042
+ logger.warning(
2043
+ "Transient provider error on turn %d, "
2044
+ "retry %d/%d after %.1fs: %s",
2045
+ turn + 1, turn_retries, max_retries, delay, e,
2046
+ )
2047
+ self._turn_events.append({
2048
+ "turn": turn + 1,
2049
+ "kind": "provider_error_retry",
2050
+ "retry": turn_retries,
2051
+ "delay": round(delay, 2),
2052
+ "message": str(e),
2053
+ })
2054
+ yield StreamEvent.text_delta(
2055
+ f"\n\n*Transient error, retrying in {delay:.1f}s…*\n\n"
2056
+ )
2057
+ await asyncio.sleep(delay)
2058
+ continue
2059
+ ctx_msgs = len(self.context.messages)
2060
+ ctx_tokens = self.context._token_count
2061
+ logger.exception(
2062
+ f"Transient provider error on turn {turn+1} "
2063
+ f"(exhausted {turn_retries} retries): {e}\n"
2064
+ f" provider={provider_name} model={model_name} "
2065
+ f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
2066
+ )
2067
+ self._turn_events.append({
2068
+ "turn": turn + 1,
2069
+ "kind": "provider_error_exhausted",
2070
+ "message": str(e),
2071
+ })
2072
+ yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
2073
+ if self._has_pending_todos():
2074
+ pending = sum(1 for t in self._todo_manager.list_todos()
2075
+ if t.get("status") in ("pending", "in_progress"))
2076
+ yield StreamEvent.text_delta(
2077
+ f"\n\n*Transient error exhausted with {pending} pending todos. Pausing for user input...*\n\n"
2078
+ )
2079
+ return
2080
+ break
2081
+ except (KeyboardInterrupt, asyncio.CancelledError):
2082
+ self._cleanup_injected_messages()
2083
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2084
+ return
2085
+ except Exception as e:
2086
+ ctx_msgs = len(self.context.messages)
2087
+ ctx_tokens = self.context._token_count
2088
+ logger.exception(
2089
+ f"Error during chat_stream_response turn {turn+1}: {e}\n"
2090
+ f" provider={provider_name} model={model_name} "
2091
+ f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
2092
+ )
2093
+ if self._has_pending_todos():
2094
+ pending = sum(1 for t in self._todo_manager.list_todos()
2095
+ if t.get("status") in ("pending", "in_progress"))
2096
+ logger.info(f"Error with %d pending todos, attempting to continue...", pending)
2097
+ yield StreamEvent.text_delta(
2098
+ f"\n\n*Error: {safe_error_msg(e)}, {pending} todos remaining. Attempting to continue...*\n\n"
2099
+ )
2100
+ continue
2101
+ yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
2102
+ break
2103
+
2104
+ # Check cancellation after provider call (covers non-streaming
2105
+ # responses and cases where on_text_chunk was never called).
2106
+ if self._cancelled:
2107
+ self._cleanup_injected_messages()
2108
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2109
+ return
2110
+
2111
+ # Track usage
2112
+ self._turn_usages.append(turn_usage)
2113
+ if turn_usage:
2114
+ self.total_tokens += turn_usage.get("total_tokens", 0)
2115
+
2116
+ # Extract thinking blocks from response. In the streaming path
2117
+ # the adapter's _make_stream_callback already stripped <thinking>
2118
+ # markers from the text and routed them through self.on_thinking
2119
+ # (which appends to _pending_thinking_blocks). The non-streaming
2120
+ # fallback still has the markers inline, so we parse them here.
2121
+ thinking_blocks = list(self._pending_thinking_blocks)
2122
+ clean_text = response_text
2123
+ for match in THINKING_RE.finditer(response_text):
2124
+ tb = match.group(1)
2125
+ if tb not in thinking_blocks:
2126
+ thinking_blocks.append(tb)
2127
+ if thinking_blocks or self._pending_thinking_blocks:
2128
+ clean_text = THINKING_RE.sub('', response_text).strip()
2129
+ if not self._streaming_used:
2130
+ for tb in thinking_blocks:
2131
+ yield StreamEvent.thinking(tb)
2132
+
2133
+ # Log the raw model response so developers can verify the
2134
+ # model is actually emitting ``<thinking>`` markers (and not
2135
+ # bleeding planning prose into the visible answer). Goes
2136
+ # to the onecode root logger → ~/.onecode/logs/onecode.log when
2137
+ # ``setup_logging(DEBUG)`` is in effect.
2138
+ logger.debug(
2139
+ "RAW_RESPONSE turn=%d text_len=%d first_200=%r "
2140
+ "thinking_blocks=%d tool_uses=%d streaming_used=%s",
2141
+ turn + 1,
2142
+ len(response_text),
2143
+ response_text[:200],
2144
+ len(thinking_blocks),
2145
+ len(tool_uses),
2146
+ self._streaming_used,
2147
+ )
2148
+
2149
+ # Add assistant response to context with proper content blocks.
2150
+ # Persist thinking blocks so they survive session reload — the
2151
+ # TUI replays them as collapsible Thought widgets.
2152
+ if tool_uses or thinking_blocks:
2153
+ assistant_blocks: list = []
2154
+ for tb in thinking_blocks:
2155
+ assistant_blocks.append({"type": "thinking", "thinking": tb})
2156
+ if clean_text:
2157
+ assistant_blocks.append({"type": "text", "text": clean_text})
2158
+ for tu in tool_uses:
2159
+ assistant_blocks.append({
2160
+ "type": "tool_use",
2161
+ "id": tu["id"],
2162
+ "name": tu["name"],
2163
+ "input": tu["input"],
2164
+ })
2165
+ self.context.add_assistant(assistant_blocks)
2166
+ else:
2167
+ self.context.add_assistant(clean_text)
2168
+
2169
+ self.iterations += 1
2170
+ self._ctx_err_retries = 0
2171
+
2172
+ # Always yield text content as TEXT_DELTA, regardless of tool uses.
2173
+ # Without this, subagent text is consumed silently when it also
2174
+ # emits tool calls — the text goes to context but never reaches
2175
+ # _spawn_subagent_async_streaming / the subagent_chunk stream.
2176
+ if not self._streaming_used and clean_text.strip():
2177
+ for i in range(0, len(clean_text), 12):
2178
+ yield StreamEvent.text_delta(clean_text[i:i + 12])
2179
+
2180
+ if not tool_uses:
2181
+ # ── Store conversation in long-term memory ──
2182
+ if self._session:
2183
+ try:
2184
+ conv_id = self._session.id
2185
+ input_str = user_input if isinstance(user_input, str) else str(user_input)
2186
+ self._memory.remember(
2187
+ MemoryLayer.L0_CONVERSATION,
2188
+ f"user: {input_str}\nassistant: {clean_text}",
2189
+ {"conversation_id": conv_id},
2190
+ )
2191
+ except Exception as e:
2192
+ logger.warning("Memory store failed: %s", e)
2193
+
2194
+ # ── Force continuation if todos remain ──
2195
+ # After subagents complete, the LLM may stop calling tools
2196
+ # because the structured output strongly signals "done".
2197
+ # If there are still pending/in-progress todos, inject a
2198
+ # forceful continuation nudge and loop again instead of
2199
+ # exiting the turn loop.
2200
+ self._empty_tool_turns += 1
2201
+ max_empty_turns = getattr(self._cfg.agent, 'max_empty_turns', 10)
2202
+ if self._has_pending_todos():
2203
+ if self._empty_tool_turns >= max_empty_turns:
2204
+ pending_count = sum(
2205
+ 1 for t in self._todo_manager.list_todos()
2206
+ if t.get("status") in ("pending", "in_progress")
2207
+ )
2208
+ logger.warning(
2209
+ "LLM produced %d consecutive empty turns with %d "
2210
+ "remaining todos — pausing for user input",
2211
+ self._empty_tool_turns, pending_count,
2212
+ )
2213
+ yield StreamEvent.text_delta(
2214
+ f"\n\n*Pausing: {self._empty_tool_turns} empty turns with {pending_count} pending todos. "
2215
+ "Type 'continue' to proceed or 'stop' to end.*\n\n"
2216
+ )
2217
+ return
2218
+ open_count = sum(
2219
+ 1 for t in self._todo_manager.list_todos()
2220
+ if t.get("status") in ("pending", "in_progress")
2221
+ )
2222
+ logger.info(
2223
+ "LLM stopped calling tools but %d pending todos remain — "
2224
+ "injecting FORCE_CONTINUE nudge (empty_tool_turns=%d)",
2225
+ open_count, self._empty_tool_turns,
2226
+ )
2227
+ next_todo = None
2228
+ for t in self._todo_manager.list_todos():
2229
+ if t.get("status") == "pending":
2230
+ next_todo = t
2231
+ break
2232
+ if next_todo:
2233
+ lines = [
2234
+ "# Continue working",
2235
+ "",
2236
+ f"The next pending todo is `{next_todo['id']}`: "
2237
+ f"{next_todo.get('subject', '')}",
2238
+ "",
2239
+ "Execute it now. Do NOT summarise or stop.",
2240
+ ]
2241
+ self._inject_nudge(lines)
2242
+ else:
2243
+ lines = [
2244
+ "<!-- FORCE_CONTINUE -->",
2245
+ "## CRITICAL: Unfinished todos detected",
2246
+ "You stopped without completing all planned todos.",
2247
+ "You MUST continue working:",
2248
+ "1. Mark any finished work as complete.",
2249
+ "2. Then Spawn or execute the next pending todo.",
2250
+ "3. Repeat until ALL todos are Done.",
2251
+ "Do NOT stop. Do NOT summarise. Keep executing.",
2252
+ ]
2253
+ self._inject_nudge(lines)
2254
+ continue
2255
+ # All todos completed — end session immediately
2256
+ all_todos = self._todo_manager.list_todos()
2257
+ if all_todos and all(t.get("status") == "completed" for t in all_todos):
2258
+ logger.info("All %d todo(s) completed, ending session", len(all_todos))
2259
+ break
2260
+ if self._empty_tool_turns >= max_empty_turns:
2261
+ logger.warning(
2262
+ "LLM produced %d consecutive empty turns with no pending todos — breaking loop",
2263
+ self._empty_tool_turns,
2264
+ )
2265
+ break
2266
+ # No pending todos and no tool calls: work appears done.
2267
+ if not all_todos:
2268
+ break
2269
+
2270
+ # Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
2271
+ for tu in tool_uses:
2272
+ self._notify_event(ToolEvent(
2273
+ kind="tool_use",
2274
+ tool_name=tu["name"],
2275
+ tool_input=tu["input"],
2276
+ tool_use_id=tu["id"],
2277
+ ))
2278
+ yield StreamEvent.tool_call_start(tu["name"], tu["id"])
2279
+ for tu in tool_uses:
2280
+ yield StreamEvent.tool_call_complete(tu["id"], tu["name"], tu["input"])
2281
+
2282
+ for tu in tool_uses:
2283
+ if self._cancelled:
2284
+ self._cleanup_injected_messages()
2285
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2286
+ return
2287
+ logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
2288
+
2289
+ # ── Plan mode guard ──
2290
+ # In plan mode (edit=DENY && bash=DENY), reject any tool not
2291
+ # marked as read-only UNLESS the permission system explicitly
2292
+ # allows it (e.g. TodoCreate/TodoUpdate, which are the plan
2293
+ # artifact and are ALLOWed for the plan agent). Returns a
2294
+ # clear message telling the LLM to switch to Build mode
2295
+ # instead of silently failing.
2296
+ if self._is_plan_mode():
2297
+ spec = self._tool_registry.get(tu["name"])
2298
+ if spec and not spec.spec().is_read_only:
2299
+ denied = self._check_tool_permission(tu["name"], tu["input"])
2300
+ if denied is not None:
2301
+ self._plan_denial_count += 1
2302
+ self._register_tool_denial(tu["name"])
2303
+ result = {
2304
+ "tool_use_id": tu["id"],
2305
+ "is_error": True,
2306
+ "category": "mode",
2307
+ "content": json.dumps({
2308
+ "success": False,
2309
+ "error": (
2310
+ f"Tool '{tu['name']}' is not available in Plan mode. "
2311
+ "Plan mode is read-only: you may use Read/Glob/Grep/WebFetch/"
2312
+ "WebSearch for analysis, AskUser for questions, and "
2313
+ "TodoCreate/TodoUpdate to build the task list. "
2314
+ "Writing files, running shell commands, or any "
2315
+ "other mutation requires switching to Build mode."
2316
+ ),
2317
+ }),
2318
+ }
2319
+ if self._plan_denial_count >= 3:
2320
+ self.context.insert_system_before_non_system(
2321
+ "<!-- PLAN_MODE_DENIED -->\n"
2322
+ "## CRITICAL: Repeated write attempts in Plan mode\n"
2323
+ "You have repeatedly attempted write operations in Plan mode.\n"
2324
+ "This will NEVER succeed. Stop trying and use only read/planning tools.\n"
2325
+ "To execute changes, the user must switch to Build mode first."
2326
+ )
2327
+ else:
2328
+ self.context.insert_system_before_non_system(
2329
+ "<!-- PLAN_MODE_DENIED -->\n"
2330
+ f"You attempted to use '{tu['name']}' which requires write access.\n"
2331
+ "Plan mode is read-only. Do NOT call write-capable tools again.\n"
2332
+ "Switch to Build mode if you need to execute changes."
2333
+ )
2334
+ # Emit tool result event before continuing to next tool
2335
+ result_str = str(result.get("content", ""))
2336
+ is_error = result.get("is_error", False)
2337
+ _cat = result.get("category", "unknown")
2338
+ from onecode.models.messages import ToolCategory as MsgToolCategory
2339
+ try:
2340
+ result_cat = MsgToolCategory(_cat)
2341
+ except ValueError:
2342
+ result_cat = MsgToolCategory.UNKNOWN
2343
+ self._notify_event(ToolEvent(
2344
+ kind="tool_result",
2345
+ tool_name=tu["name"],
2346
+ tool_use_id=tu["id"],
2347
+ tool_output=result_str,
2348
+ is_error=is_error,
2349
+ ))
2350
+ yield StreamEvent.tool_result(
2351
+ call_id=tu["id"],
2352
+ content=result_str,
2353
+ is_error=is_error,
2354
+ category=result_cat,
2355
+ )
2356
+ if is_anthropic:
2357
+ self.context.add_tool_result(tu["id"], result_str, is_error)
2358
+ else:
2359
+ self.context.add_message(
2360
+ "tool",
2361
+ [{"type": "tool_result", "tool_use_id": tu["id"],
2362
+ "content": result_str, "is_error": is_error}],
2363
+ name=tu["id"],
2364
+ )
2365
+ continue
2366
+
2367
+ # Spawn tool: forward subagent text deltas to the TUI as
2368
+ # subagent_chunk events so the SubAgent widget actually has
2369
+ # content to render. The Spawn tool's spec is (agent_type,
2370
+ # prompt); we read them from the tool input.
2371
+ if tu["name"] == "Spawn":
2372
+ # Permission check
2373
+ denied = self._check_tool_permission("Spawn", tu["input"])
2374
+ if denied:
2375
+ result = {
2376
+ "tool_use_id": tu["id"],
2377
+ "is_error": True,
2378
+ "category": "task",
2379
+ "content": denied,
2380
+ }
2381
+ else:
2382
+ subagent_type = tu["input"].get("agent_type", "general")
2383
+ subagent_prompt = tu["input"].get("prompt", "")
2384
+ _sp_id = tu["id"]
2385
+ _sp_bytes_fwd = 0
2386
+ _sp_chunk_count = 0
2387
+ logger.debug(
2388
+ "[SPAWN-PARENT %s] start subagent_type=%s prompt_len=%d "
2389
+ "tool_id=%s cancelled=%s",
2390
+ _sp_id, subagent_type, len(subagent_prompt),
2391
+ _sp_id, self._cancelled,
2392
+ )
2393
+ is_failed = False
2394
+ error_msg = ""
2395
+ yield StreamEvent.subagent_start(subagent_type, tu["id"], subagent_prompt)
2396
+ accumulated: list[str] = []
2397
+ try:
2398
+ async for sub_event, sub_text in self._spawn_subagent_async_streaming(
2399
+ subagent_type, subagent_prompt, subagent_id=tu["id"]
2400
+ ):
2401
+ _sp_chunk_count += 1
2402
+ if self._cancelled:
2403
+ logger.debug(
2404
+ "[SPAWN-PARENT %s] cancelled mid-iter "
2405
+ "chunk=%d → break", _sp_id, _sp_chunk_count)
2406
+ break
2407
+ if sub_event.type == StreamEventType.SUBAGENT_END:
2408
+ if hasattr(sub_event, "subagent_status") and sub_event.subagent_status == "failed":
2409
+ is_failed = True
2410
+ error_msg = sub_event.subagent_error or ""
2411
+ logger.debug(
2412
+ "[SPAWN-PARENT %s] recv SUBAGENT_END "
2413
+ "status=%s → continue",
2414
+ _sp_id, getattr(sub_event, "subagent_status", "?"),
2415
+ )
2416
+ continue # 修复 Bug 1: 不 append final combined text(避免文本重复)
2417
+ if sub_event.type == StreamEventType.SUBAGENT_THINKING:
2418
+ yield StreamEvent.subagent_thinking(tu["id"], sub_text)
2419
+ continue
2420
+ # Structured subagent tool events: forward as-is
2421
+ # (they already carry subagent_id=tu["id"]) so the
2422
+ # ACP emits tool_call/tool_call_update sessionUpdates
2423
+ # tagged with subagentId and the TUI renders real
2424
+ # ToolCall cards inside the SubAgent widget.
2425
+ if sub_event.type in (
2426
+ StreamEventType.SUBAGENT_TOOL_CALL,
2427
+ StreamEventType.SUBAGENT_TOOL_RESULT,
2428
+ ):
2429
+ yield sub_event
2430
+ continue
2431
+ if not sub_text:
2432
+ continue
2433
+ accumulated.append(sub_text)
2434
+ _sp_bytes_fwd += len(sub_text)
2435
+ if _sp_chunk_count <= 5 or _sp_chunk_count % 50 == 0:
2436
+ logger.debug(
2437
+ "[SPAWN-PARENT %s] fwd chunk#%d bytes=%d "
2438
+ "total=%d",
2439
+ _sp_id, _sp_chunk_count, len(sub_text),
2440
+ _sp_bytes_fwd,
2441
+ )
2442
+ yield StreamEvent.subagent_chunk(tu["id"], sub_text)
2443
+ except asyncio.CancelledError:
2444
+ logger.debug(
2445
+ "[SPAWN-PARENT %s] CancelledError → subagent_end "
2446
+ "failed=cancelled + re-raise", _sp_id)
2447
+ yield StreamEvent.subagent_end(
2448
+ tu["id"],
2449
+ agent_type=subagent_type,
2450
+ status="failed",
2451
+ error="cancelled",
2452
+ )
2453
+ raise
2454
+ logger.debug(
2455
+ "[SPAWN-PARENT %s] subagent done fwd_chunks=%d "
2456
+ "bytes_fwd=%d is_failed=%s err=%r → yield subagent_end",
2457
+ _sp_id, _sp_chunk_count, _sp_bytes_fwd, is_failed,
2458
+ error_msg[:80],
2459
+ )
2460
+ yield StreamEvent.subagent_end(
2461
+ tu["id"],
2462
+ agent_type=subagent_type,
2463
+ status="failed" if is_failed else "completed",
2464
+ error=error_msg,
2465
+ )
2466
+ raw_output = "".join(accumulated)
2467
+ logger.debug(
2468
+ "[SPAWN-PARENT %s] formatting result raw_len=%d "
2469
+ "is_failed=%s", _sp_id, len(raw_output), is_failed,
2470
+ )
2471
+ formatted = self._format_subagent_output(
2472
+ subagent_type, subagent_prompt, raw_output
2473
+ ) if not is_failed else (
2474
+ f"SUMMARY:\nSub-agent failed with error: {error_msg}\n\n"
2475
+ "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
2476
+ f"BLOCKERS:\n{error_msg}"
2477
+ )
2478
+ result = {
2479
+ "tool_use_id": tu["id"],
2480
+ "is_error": is_failed, # 修复 Bug 2: 子智能体失败时 is_error=True
2481
+ "category": "task",
2482
+ "content": formatted,
2483
+ "raw_content": raw_output,
2484
+ }
2485
+ # Auto-advance todo after subagent completes
2486
+ if not is_failed:
2487
+ self._auto_advance_after_spawn(subagent_prompt)
2488
+ elif (self._plan_gate_mode != "off"
2489
+ and not self._has_pending_todos()
2490
+ and tu["name"] in _EXECUTION_TOOLS):
2491
+ if self._plan_gate_mode == "hard":
2492
+ result = {
2493
+ "tool_use_id": tu["id"],
2494
+ "is_error": True,
2495
+ "category": "todo",
2496
+ "content": json.dumps({
2497
+ "success": False,
2498
+ "error": (
2499
+ "Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
2500
+ "Before using execution tools, output a step-by-step plan as "
2501
+ "Markdown for user review, and only then proceed to execute."
2502
+ ),
2503
+ }),
2504
+ }
2505
+ else:
2506
+ # Soft gate: execute but inject a routing reminder for next turn
2507
+ self.context.insert_system_before_non_system(
2508
+ "<!-- PLAN_REMINDER -->\n"
2509
+ "## Routing Decision\n"
2510
+ "Output a step-by-step plan as Markdown before executing.\n"
2511
+ "Then route EXECUTION by complexity:\n\n"
2512
+ "**Simple / single-step** → execute directly with "
2513
+ "`Read`/`Edit`/`Bash`.\n\n"
2514
+ "**Complex / multi-step / needs isolated context** → "
2515
+ "`Spawn(agent_type, prompt)` delegates to a focused subagent.\n"
2516
+ )
2517
+ result = await self._execute_tool(tu)
2518
+ if self._just_exited_plan_mode:
2519
+ spec = self._tool_registry.get(tu["name"])
2520
+ if spec and not spec.spec().is_read_only:
2521
+ self._just_exited_plan_mode = False
2522
+ else:
2523
+ result = await self._execute_tool(tu)
2524
+ # Reset _just_exited_plan_mode once real work begins
2525
+ if self._just_exited_plan_mode:
2526
+ spec = self._tool_registry.get(tu["name"])
2527
+ if spec and not spec.spec().is_read_only:
2528
+ self._just_exited_plan_mode = False
2529
+ # Routing: track direct execution tool use → nudge routing decision
2530
+ if tu["name"] in _EXECUTION_TOOLS:
2531
+ self._direct_execution_count += 1
2532
+ if self._direct_execution_count >= 2:
2533
+ self.context.insert_system_before_non_system(
2534
+ "<!-- ROUTING_REMINDER -->\n"
2535
+ "You've used direct execution tools repeatedly. Pause and decide:\n"
2536
+ "- Is this work part of a plan? If not, output a plan as Markdown first.\n"
2537
+ "- If the task is a single-step trivial change → continue directly.\n"
2538
+ "- If this work involves >1 tool call, multiple files, or "
2539
+ "research → STOP direct execution and delegate via "
2540
+ "`Spawn(agent_type=\"general\", prompt=\"...\")` instead."
2541
+ )
2542
+ result_str = str(result.get("content", ""))
2543
+ is_error = result.get("is_error", False)
2544
+ category = result.get("category", "unknown")
2545
+ # For Spawn tools, use raw subagent output for TUI display
2546
+ # (no SUMMARY/CHANGES/… structured wrapper)
2547
+ tui_content = str(result.get("raw_content", result_str))
2548
+
2549
+ # Handle SendMessage — user-visible only, skip from LLM context
2550
+ if tu["name"] == "SendMessage":
2551
+ try:
2552
+ parsed = json.loads(result_str) if result_str else {}
2553
+ msg = parsed.get("message", "") if isinstance(parsed, dict) else ""
2554
+ if msg:
2555
+ self._last_user_msg = msg
2556
+ yield StreamEvent.text_delta(f"\n💬 {msg}\n")
2557
+ except (json.JSONDecodeError, ValueError):
2558
+ pass
2559
+ self._notify_event(ToolEvent(
2560
+ kind="tool_result",
2561
+ tool_name="SendMessage",
2562
+ tool_use_id=tu["id"],
2563
+ tool_output={"message": self._last_user_msg},
2564
+ ))
2565
+ continue
2566
+
2567
+ # Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
2568
+ if tu["name"] == "AskUser":
2569
+ parsed = json.loads(result_str) if result_str else {}
2570
+ question = parsed.get("question", "") if isinstance(parsed, dict) else ""
2571
+ context = parsed.get("context", "") if isinstance(parsed, dict) else ""
2572
+ options = parsed.get("options", []) if isinstance(parsed, dict) else []
2573
+ questions = parsed.get("questions", []) if isinstance(parsed, dict) else []
2574
+ plan_submit = bool(parsed.get("plan_submit", False)) if isinstance(parsed, dict) else False
2575
+
2576
+ # Plan submission: persist the plan document so it survives
2577
+ # compaction and can be re-read by the execution agent.
2578
+ plan_path = None
2579
+ if plan_submit:
2580
+ plan_path = self._save_plan_document(
2581
+ context or question or result_str
2582
+ )
2583
+ if plan_path:
2584
+ self._approved_plan_path = plan_path
2585
+ suffix = f"\n\nPlan saved to: {plan_path}"
2586
+ if context:
2587
+ context = f"{context}{suffix}"
2588
+ elif question:
2589
+ question = f"{question}{suffix}"
2590
+
2591
+ # Check for auto-default on single question + single option
2592
+ if not questions and not plan_submit and len(options) == 1 and options[0].get("default"):
2593
+ default_val = options[0]["value"]
2594
+ self._pending_approval = None
2595
+ yield StreamEvent.tool_result(
2596
+ call_id=tu["id"],
2597
+ content=json.dumps({"answer": default_val}),
2598
+ )
2599
+ continue
2600
+
2601
+ # Auto-default for multi-question: skip questions that have
2602
+ # a single option with default=true
2603
+ if questions and not plan_submit:
2604
+ auto_answers = {}
2605
+ remaining = []
2606
+ for i, q in enumerate(questions):
2607
+ qopts = q.get("options", [])
2608
+ if len(qopts) == 1 and qopts[0].get("default"):
2609
+ auto_answers[str(i)] = qopts[0]["value"]
2610
+ else:
2611
+ remaining.append(q)
2612
+ if not remaining:
2613
+ self._pending_approval = None
2614
+ yield StreamEvent.tool_result(
2615
+ call_id=tu["id"],
2616
+ content=json.dumps({"answers": auto_answers}),
2617
+ )
2618
+ continue
2619
+ questions = remaining
2620
+
2621
+ has_questions = bool(questions)
2622
+ self._pending_approval = {
2623
+ "tool_call": tu,
2624
+ "category": category,
2625
+ "ask_user": True,
2626
+ "plan_submit": plan_submit,
2627
+ "plan_path": plan_path,
2628
+ }
2629
+ if has_questions:
2630
+ yield StreamEvent.ask_user(
2631
+ call_id=tu["id"],
2632
+ action="AskUser",
2633
+ question="", # not used when questions is present
2634
+ context=result_str,
2635
+ options=[],
2636
+ questions=questions,
2637
+ action_type="ask_user",
2638
+ plan_submit=plan_submit,
2639
+ )
2640
+ else:
2641
+ yield StreamEvent.ask_user(
2642
+ call_id=tu["id"],
2643
+ action="AskUser",
2644
+ question=question,
2645
+ context=result_str,
2646
+ options=options,
2647
+ action_type="ask_user",
2648
+ plan_submit=plan_submit,
2649
+ )
2650
+ continue
2651
+
2652
+ # Detect ASK permission denial
2653
+ requires_approval = False
2654
+ try:
2655
+ parsed = json.loads(result_str) if result_str else {}
2656
+ requires_approval = isinstance(parsed, dict) and parsed.get("requires_approval", False)
2657
+ except (json.JSONDecodeError, ValueError):
2658
+ pass
2659
+
2660
+ if requires_approval:
2661
+ checkpoint_id = ""
2662
+ tool_input = tu.get("input", {})
2663
+ affected_files = self._get_affected_files(tu["name"], tool_input)
2664
+ should_ckpt, reason = self._checkpoint_manager.should_checkpoint(
2665
+ tu["name"], tool_input, affected_files
2666
+ )
2667
+ if should_ckpt:
2668
+ checkpoint_id = self._checkpoint_manager.create(
2669
+ agent=self.current_agent.name,
2670
+ reason=reason,
2671
+ files=affected_files,
2672
+ tool_call_id=tu["id"],
2673
+ description=f"{tu['name']} requires approval",
2674
+ )
2675
+ self._pending_approval = {
2676
+ "tool_call": tu,
2677
+ "category": category,
2678
+ "checkpoint_id": checkpoint_id,
2679
+ }
2680
+ self._notify_event(ToolEvent(
2681
+ kind="tool_result",
2682
+ tool_name=tu["name"],
2683
+ tool_use_id=tu["id"],
2684
+ is_error=True,
2685
+ error="Requires approval",
2686
+ ))
2687
+ yield StreamEvent.ask_user(
2688
+ call_id=tu["id"],
2689
+ action=tu["name"],
2690
+ question=f"Approve {tu['name']} operation?",
2691
+ context=result_str,
2692
+ action_type=tu["name"].lower(),
2693
+ path=tu["input"].get("path", ""),
2694
+ command=tu["input"].get("command", "")[:200],
2695
+ checkpoint_id=checkpoint_id,
2696
+ )
2697
+ # Generator resumes here after approval. The approved tool
2698
+ # may have mutated todos (e.g. TodoUpdate via resolve_approval),
2699
+ # flushing _plan_dirty so the TUI stays in sync.
2700
+ if self._plan_dirty:
2701
+ for event in self._emit_plan_update():
2702
+ yield event
2703
+ self._plan_dirty = False
2704
+ else:
2705
+ from onecode.models.messages import ToolCategory as MsgToolCategory
2706
+ try:
2707
+ result_cat = MsgToolCategory(category)
2708
+ except ValueError:
2709
+ result_cat = MsgToolCategory.UNKNOWN
2710
+ self._last_tool_results[tu["id"]] = result_str
2711
+ self._notify_event(ToolEvent(
2712
+ kind="tool_result",
2713
+ tool_name=tu["name"],
2714
+ tool_output=result_str,
2715
+ tool_use_id=tu["id"],
2716
+ is_error=is_error,
2717
+ ))
2718
+ yield StreamEvent.tool_result(
2719
+ call_id=tu["id"],
2720
+ content=tui_content,
2721
+ is_error=is_error,
2722
+ category=result_cat,
2723
+ )
2724
+ # Add structured tool_result to LLM context
2725
+ if is_anthropic:
2726
+ self.context.add_tool_result(tu["id"], result_str, is_error)
2727
+ else:
2728
+ self.context.add_message(
2729
+ "tool",
2730
+ [{"type": "tool_result", "tool_use_id": tu["id"], "content": result_str, "is_error": is_error}],
2731
+ name=tu["id"],
2732
+ )
2733
+ # Mid-turn compaction: tool results can add significant
2734
+ # context; compress immediately if we're over threshold.
2735
+ self.context.set_model(self.app.current_model)
2736
+ if self.context.should_compact():
2737
+ try:
2738
+ level = self.context.compact()
2739
+ if level not in ("none",):
2740
+ logger.info(
2741
+ "Mid-turn compaction at turn %d after tool %s: %s",
2742
+ turn + 1, tu["name"], level,
2743
+ )
2744
+ except Exception as e:
2745
+ logger.error(
2746
+ "Mid-turn compaction failed at turn %d after tool %s: %s",
2747
+ turn + 1, tu["name"], e,
2748
+ )
2749
+ # Emit plan update when tasks/todos changed. Any
2750
+ # mutating path through TodoManager flips
2751
+ # ``_plan_dirty`` via the ``on_change`` callback, so we
2752
+ # do not need to enumerate tool names here.
2753
+ if self._plan_dirty:
2754
+ for event in self._emit_plan_update():
2755
+ yield event
2756
+ self._plan_dirty = False
2757
+
2758
+ # Tools were called this turn — reset the empty-turn counter
2759
+ self._empty_tool_turns = 0
2760
+
2761
+ # L2: Run verification loop if applicable
2762
+ verification_retry = False
2763
+ if self._verification_loop is not None:
2764
+ from onecode.agent.turn_record import TurnRecord
2765
+ for tu in tool_uses:
2766
+ if self._verification_loop.should_verify(tu["name"]):
2767
+ result_str = self._last_tool_results.get(tu["id"], "")
2768
+ turn_record = TurnRecord(
2769
+ turn_number=turn,
2770
+ thought=clean_text,
2771
+ tool_name=tu["name"],
2772
+ tool_input=tu.get("input"),
2773
+ tool_output=result_str,
2774
+ )
2775
+ agg_result = await self._verification_loop.run_gates(turn_record)
2776
+ turn_record.add_verification(agg_result.to_dict())
2777
+ evt_type = StreamEventType.VERIFICATION_PASSED if agg_result.passed else StreamEventType.VERIFICATION_FAILED
2778
+ yield StreamEvent(
2779
+ type=evt_type,
2780
+ text="passed" if agg_result.passed else f"failed: {', '.join(agg_result.failed_gates)}",
2781
+ verification_passed=agg_result.passed,
2782
+ verification_failed_gates=list(agg_result.failed_gates),
2783
+ )
2784
+ if not agg_result.passed:
2785
+ self._verification_fail_count += 1
2786
+ logger.warning(
2787
+ "Verification failed (attempt %d/3): %s",
2788
+ self._verification_fail_count, agg_result.failed_gates,
2789
+ )
2790
+ self._turn_events.append({
2791
+ "turn": turn + 1,
2792
+ "kind": "verify_fail",
2793
+ "gates": list(agg_result.failed_gates),
2794
+ "attempt": self._verification_fail_count,
2795
+ })
2796
+ if self._verification_fail_count <= 3:
2797
+ lines = [
2798
+ "<!-- VERIFY_FAIL -->",
2799
+ f"## Verification failed: {', '.join(agg_result.failed_gates)}",
2800
+ f"Attempt {self._verification_fail_count}/3 — fix the issues above and retry.",
2801
+ "Do NOT stop. Fix the code and re-run verification tools.",
2802
+ ]
2803
+ self.context.insert_system_before_non_system("\n".join(lines))
2804
+ if self._event_bridge is not None:
2805
+ self._event_bridge.on_verification_failed(agg_result)
2806
+ verification_retry = True
2807
+ break
2808
+ else:
2809
+ logger.warning(
2810
+ "Verification failed after %d retries, stopping execution",
2811
+ self._verification_fail_count,
2812
+ )
2813
+ self._turn_events.append({
2814
+ "turn": turn + 1,
2815
+ "kind": "verify_fail_exhausted",
2816
+ "gates": list(agg_result.failed_gates),
2817
+ })
2818
+ yield StreamEvent.text_delta(
2819
+ f"\n⚠️ Verification failed after 3 retries: {', '.join(agg_result.failed_gates)}\n"
2820
+ )
2821
+ if self._event_bridge is not None:
2822
+ self._event_bridge.on_verification_failed(agg_result)
2823
+ self._cleanup_injected_messages()
2824
+ return
2825
+ else:
2826
+ self._verification_fail_count = 0
2827
+ if self._event_bridge is not None:
2828
+ self._event_bridge.on_verification_passed()
2829
+ if verification_retry:
2830
+ continue
2831
+
2832
+ if self._cancelled:
2833
+ self._cleanup_injected_messages()
2834
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2835
+ return
2836
+
2837
+ self._cleanup_injected_messages()
2838
+ usage_summary = ", ".join(
2839
+ f"turn {i+1}: {u.get('total_tokens', '?')} tokens"
2840
+ for i, u in enumerate(self._turn_usages) if u
2841
+ )
2842
+ logger.info(f"Chat stream complete after {self.iterations} round(s). Usage: [{usage_summary}]")
2843
+
2844
+ # ── Session end: clear todos if all completed ──
2845
+ # When every todo has reached "completed" status, wipe the todo list
2846
+ # so the next session starts clean instead of inheriting a stale plan.
2847
+ try:
2848
+ final_todos = self._todo_manager.list_todos()
2849
+ if final_todos and all(t.get("status") == "completed" for t in final_todos):
2850
+ logger.info(
2851
+ "Session end: all %d todo(s) completed, clearing todo list",
2852
+ len(final_todos),
2853
+ )
2854
+ self._todo_manager.clear_todos()
2855
+ except Exception as e:
2856
+ logger.warning("Session-end todo cleanup failed: %s", e, exc_info=True)
2857
+
2858
+ def has_pending_approval(self) -> bool:
2859
+ """Check if there's a pending approval request from ASK permission."""
2860
+ return self._pending_approval is not None
2861
+
2862
+ async def resolve_approval(self, approved: bool, answer: str = "") -> dict | None:
2863
+ """Execute the pending action if approved, or return denial.
2864
+
2865
+ For AskUser, ``answer`` is the user's free-text response.
2866
+
2867
+ Returns the tool result dict, or None if no pending approval.
2868
+ """
2869
+ from onecode.agent.agents.types import AgentPermission
2870
+ if not self._pending_approval:
2871
+ return None
2872
+
2873
+ tc = self._pending_approval["tool_call"]
2874
+ is_ask_user = self._pending_approval.get("ask_user", False)
2875
+ plan_submit = self._pending_approval.get("plan_submit", False)
2876
+ plan_path = self._pending_approval.get("plan_path")
2877
+ checkpoint_id = self._pending_approval.get("checkpoint_id", "")
2878
+ self._pending_approval = None
2879
+
2880
+ # Handle AskUser — return user's answer as tool result
2881
+ if is_ask_user:
2882
+ if not approved:
2883
+ return {
2884
+ "tool_use_id": tc["id"],
2885
+ "content": json.dumps({"answer": "", "error": "User cancelled"}),
2886
+ "is_error": True,
2887
+ "category": tc.get("category", "unknown"),
2888
+ }
2889
+ # Plan approved → hand off to the execution agent (keeps todos).
2890
+ if plan_submit:
2891
+ if not self._plan_handoff(plan_path):
2892
+ logger.error("Plan handoff failed but continued execution")
2893
+ # answer is a JSON string when multi-question, plain text otherwise
2894
+ try:
2895
+ parsed = json.loads(answer)
2896
+ is_structured = isinstance(parsed, dict) or isinstance(parsed, list)
2897
+ except (json.JSONDecodeError, TypeError):
2898
+ is_structured = False
2899
+ if is_structured:
2900
+ return {
2901
+ "tool_use_id": tc["id"],
2902
+ "content": json.dumps({"answers": parsed}),
2903
+ "is_error": False,
2904
+ "category": tc.get("category", "unknown"),
2905
+ }
2906
+ return {
2907
+ "tool_use_id": tc["id"],
2908
+ "content": json.dumps({"answer": answer}),
2909
+ "is_error": False,
2910
+ "category": tc.get("category", "unknown"),
2911
+ }
2912
+
2913
+ if not approved:
2914
+ return {
2915
+ "tool_use_id": tc["id"],
2916
+ "content": json.dumps({"success": False, "error": "User denied the operation"}),
2917
+ "is_error": True,
2918
+ "category": tc.get("category", "unknown"),
2919
+ }
2920
+
2921
+ if answer == "__rollback__" and checkpoint_id:
2922
+ self._checkpoint_manager.restore(checkpoint_id)
2923
+ return {
2924
+ "tool_use_id": tc["id"],
2925
+ "content": json.dumps({"success": False, "error": "Rolled back to checkpoint"}),
2926
+ "is_error": True,
2927
+ "category": tc.get("category", "unknown"),
2928
+ }
2929
+
2930
+ perm_key = self._TOOL_NAME_TO_PERM_KEY.get(tc["name"])
2931
+ if perm_key is None:
2932
+ return await self._execute_tool(tc)
2933
+
2934
+ attr_name = f"permission_{perm_key}"
2935
+ saved = getattr(self.current_agent, attr_name)
2936
+ try:
2937
+ setattr(self.current_agent, attr_name, AgentPermission.ALLOW)
2938
+ return await self._execute_tool(tc)
2939
+ finally:
2940
+ setattr(self.current_agent, attr_name, saved)
2941
+
2942
+ async def rollback_to_checkpoint(self, checkpoint_id: str) -> bool:
2943
+ """Restore files to a previous checkpoint state."""
2944
+ return self._checkpoint_manager.restore(checkpoint_id)
2945
+
2946
+ def get_checkpoint_diff(self, checkpoint_id: str, file_path: str) -> str:
2947
+ """Get diff between checkpoint and current state for a file."""
2948
+ return self._checkpoint_manager.get_diff(checkpoint_id, file_path)
2949
+
2950
+ def list_checkpoints(self, limit: int = 10) -> list[dict]:
2951
+ """List recent checkpoints."""
2952
+ return self._checkpoint_manager.list(limit=limit)
2953
+
2954
+ def _reset_react_state(self) -> None:
2955
+ """Reset ReAct loop state for a fresh cycle."""
2956
+ self._react_phase = "thought"
2957
+ self._direct_execution_count = 0
2958
+ self._empty_tool_turns = 0
2959
+ self._plan_denial_count = 0
2960
+ self._verification_fail_count = 0
2961
+ self._last_tool_results.clear()
2962
+ self._cleanup_injected_messages()
2963
+ self.context.remove_system_by_marker("<!-- PLAN_MODE_DENIED -->")
2964
+ for name in list(self._tool_denial_count):
2965
+ self.context.remove_system_by_marker(f"<!-- TOOL_DENIED_{name} -->")
2966
+ self._tool_denial_count.clear()
2967
+ self.context.remove_system_by_marker("<!-- VERIFY_FAIL -->")
2968
+ self._last_emitted_plan = ()
2969
+ self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
2970
+ self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
2971
+ self.context.remove_system_by_marker("<!-- PENDING_TODOS -->")
2972
+
2973
+ def reset(self):
2974
+ self.context.reset()
2975
+ self.iterations = 0
2976
+ self.total_tokens = 0
2977
+ self._turn_usages = []
2978
+ self._turn_events = []
2979
+ self._skills_loaded = False
2980
+ self._project_context_loaded = False
2981
+ self._ctx_crisis = False
2982
+ self._skill_loader.invalidate_cache()
2983
+ self._reset_react_state()
2984
+
2985
+ def status(self) -> str:
2986
+ return (
2987
+ f"Agent: {self.current_agent.name}\n"
2988
+ f"Iterations: {self.iterations}\n"
2989
+ f"Total tokens: {self.total_tokens}\n"
2990
+ f"Context: {self.context.info()}"
2991
+ )
2992
+
2993
+ def read_file(self, path: str, offset: int = 0, limit: int = 0) -> str:
2994
+ if self.current_agent.permission_read == AgentPermission.DENY:
2995
+ return "Read denied"
2996
+ return self.file_ops.read(path, offset, limit)
2997
+
2998
+ def write_file(self, path: str, content: str) -> dict:
2999
+ if self.current_agent.permission_edit == AgentPermission.DENY:
3000
+ return {"success": False, "error": "Edit denied"}
3001
+ if self.current_agent.permission_edit == AgentPermission.ASK:
3002
+ return {"success": False, "error": "Edit requires approval", "requires_approval": True}
3003
+ return self.file_ops.write(path, content)
3004
+
3005
+ def edit_file(self, path: str, old: str, new: str) -> dict:
3006
+ if self.current_agent.permission_edit == AgentPermission.DENY:
3007
+ return {"success": False, "error": "Edit denied"}
3008
+ if self.current_agent.permission_edit == AgentPermission.ASK:
3009
+ return {"success": False, "error": "Edit requires approval", "requires_approval": True}
3010
+ return self.file_ops.edit(path, old, new)
3011
+
3012
+ def glob_files(self, pattern: str) -> list[str]:
3013
+ return self.file_ops.glob(pattern)
3014
+
3015
+ def grep_files(self, pattern: str, include: str = None) -> list[str]:
3016
+ return self.file_ops.grep(pattern, include)
3017
+
3018
+ def list_dir(self, path: str = ".") -> list[dict]:
3019
+ return self.file_ops.list(path)
3020
+
3021
+ def exec_shell(self, cmd: str, timeout: int = 60) -> dict:
3022
+ if self.current_agent.permission_bash == AgentPermission.DENY:
3023
+ return {"success": False, "error": "Bash denied"}
3024
+ if self.current_agent.permission_bash == AgentPermission.ASK:
3025
+ return {"success": False, "error": "Bash requires approval", "requires_approval": True}
3026
+ return self.shell.exec(cmd, timeout=timeout)
3027
+
3028
+ def web_fetch(self, url: str, prompt: str = None) -> str:
3029
+ if self.current_agent.permission_webfetch == AgentPermission.DENY:
3030
+ return "WebFetch denied"
3031
+ from onecode.agent.tools.web_tools import webfetch
3032
+ return webfetch(url, prompt)
3033
+
3034
+ def web_search(self, query: str, num_results: int = 5) -> str:
3035
+ if self.current_agent.permission_websearch == AgentPermission.DENY:
3036
+ return "WebSearch denied"
3037
+ from onecode.agent.tools.web_tools import websearch
3038
+ return websearch(query, num_results)
3039
+
3040
+ async def _spawn_subagent_async(self, agent_type: str, prompt: str) -> str:
3041
+ """Spawn a sub-agent with streaming-like output.
3042
+
3043
+ Uses chat_stream to get structured output that the ChatPanel
3044
+ can parse for professional rendering.
3045
+ """
3046
+ parts: list[str] = []
3047
+ async for _event, text in self._spawn_subagent_async_streaming(agent_type, prompt):
3048
+ parts.append(text)
3049
+ result = "".join(parts)
3050
+ return self._format_subagent_output(agent_type, prompt, result)
3051
+
3052
+ async def _spawn_subagent_async_streaming(
3053
+ self, agent_type: str, prompt: str, subagent_id: str = ""
3054
+ ) -> AsyncIterator[tuple[StreamEvent, str]]:
3055
+ """Streaming variant of :meth:`_spawn_subagent_async`.
3056
+
3057
+ Yields ``(event, text)`` pairs so the caller (typically the Task-tool
3058
+ path in :meth:`chat_stream`) can forward each text delta as a
3059
+ ``subagent_chunk`` notification on the ACP wire.
3060
+
3061
+ Inherits project context and skills from the parent
3062
+ engine so the subagent does not start from a blank slate.
3063
+ """
3064
+ # Depth check: limit from config (default 1 = leaf nodes only).
3065
+ depth_limit = getattr(self.app.config.agent, 'max_subagent_depth', _MAX_SUBAGENT_DEPTH)
3066
+ subagent_timeout = getattr(self.app.config.agent, 'max_subagent_timeout', _SUBAGENT_TIMEOUT)
3067
+ if self._subagent_depth >= depth_limit:
3068
+ err = f"Subagent cannot spawn nested subagents (max depth={depth_limit})"
3069
+ yield (StreamEvent.subagent_end(
3070
+ "", agent_type=agent_type, status="failed", error=err,
3071
+ ), err)
3072
+ return
3073
+
3074
+ if self.current_agent.name == "plan" and agent_type not in ("explore", "scout"):
3075
+ err = f"PlanAgent can only spawn read-only agents (explore, scout), not '{agent_type}'"
3076
+ yield (StreamEvent.subagent_end(
3077
+ "", agent_type=agent_type, status="failed", error=err,
3078
+ ), err)
3079
+ return
3080
+
3081
+ sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
3082
+
3083
+ sub_engine._subagent_depth = self._subagent_depth + 1
3084
+
3085
+ # Inherit parent context: project info (but NOT full skill content,
3086
+ # which contains Master Orchestrator instructions that conflict with
3087
+ # the subagent role). The subagent's own _load_skills() will load
3088
+ # only non-conflicting reference info via its own SkillLoader.
3089
+ sub_engine._project_context_loaded = self._project_context_loaded
3090
+ sub_engine._project_config = dict(self._project_config)
3091
+ sub_engine._skills_loaded = False
3092
+
3093
+ # Inherit the parent's already-built codebase engine so the subagent
3094
+ # does NOT re-run the full project index (CodebaseIndexer.index()
3095
+ # is a synchronous chunk loop that would otherwise block the event
3096
+ # loop for the duration of indexing and freeze streaming/cancel).
3097
+ # Sharing the instance also avoids duplicate SQLite writes.
3098
+ sub_engine._codebase_engine = self._codebase_engine
3099
+ logger.debug(
3100
+ "[SA-STREAM %s] inheriting codebase_engine from parent: %s",
3101
+ agent_type, "shared" if self._codebase_engine is not None else "none",
3102
+ )
3103
+
3104
+ # CRITICAL: disable codebase auto-retrieval + memory recall inside
3105
+ # the subagent. Production logs prove that every Spawn hung here:
3106
+ # the subagent's ``chat_stream`` re-ran ``CodebaseIndexer.index()``
3107
+ # (a 2.3–2.6s synchronous loop, no await yield points) and then
3108
+ # ``CodebaseRetriever._retrieve_embedding()`` issued one ``httpx``
3109
+ # POST per chunk (30s timeout each, no effective cap), so a 148-
3110
+ # chunk project could block the event loop for ~minutes — during
3111
+ # which ``on_text_chunk`` never fires (no stream output) and
3112
+ # ``CancelledError`` injected by ``prompt_task.cancel()`` cannot
3113
+ # propagate (cannot be stopped). A subagent executes a specific
3114
+ # prompt handed down by the parent; it does not need project-level
3115
+ # codebase seeding or memory recall.
3116
+ sub_engine._disable_retrieval = True
3117
+ logger.debug(
3118
+ "[SA-STREAM %s] set _disable_retrieval=True (subagent skips "
3119
+ "codebase auto-retrieval + memory recall)", agent_type,
3120
+ )
3121
+
3122
+ # Do NOT propagate parent TUI-facing callbacks to the sub_engine.
3123
+ # - on_tool_call_delta: would fire the ACP's _on_tool_call_delta for
3124
+ # the subagent's inner tools (Read/Bash/...), leaking their NAMES
3125
+ # into the MAIN conversation as orphaned in_progress cards while
3126
+ # the RESULT only reaches the SubAgent widget via the stream path
3127
+ # below (tool_call_start/tool_result -> text_delta -> subagent_chunk).
3128
+ # - on_thinking: would append subagent thinking into the PARENT's
3129
+ # _pending_thinking_blocks, polluting the parent's persisted session.
3130
+ # The subagent's tool calls/thinking are already relayed to the
3131
+ # SubAgent widget via SUBAGENT_CHUNK / SUBAGENT_THINKING (see the
3132
+ # consumer loop below). on_event is left propagated: the ACP does
3133
+ # not wire it, so it is None on the parent and a no-op here.
3134
+ if self.on_event is not None:
3135
+ sub_engine.on_event = self.on_event
3136
+
3137
+ # Apply the requested agent_type's config (role description,
3138
+ # response-style / <thinking> tags, tool allow/deny list, REACT_CYCLE)
3139
+ # as the AGENT_CONFIG system section.
3140
+ sub_engine.set_agent(agent_type)
3141
+
3142
+ # Track child so parent cancellation cascades
3143
+ self._child_engines.append(sub_engine)
3144
+
3145
+ # ── Real-time streaming via asyncio.Queue ──
3146
+ # on_text_chunk puts provider tokens into the queue as they arrive,
3147
+ # and a background task puts chat_stream events into the same queue.
3148
+ # The main loop reads from the queue and yields (event, text) pairs,
3149
+ # giving the TUI live subagent output instead of "等待输出".
3150
+ _queue: asyncio.Queue[tuple[str, object]] = asyncio.Queue(maxsize=1000)
3151
+ _sa_log_prefix = f"[SA-STREAM {agent_type} depth={self._subagent_depth + 1}]"
3152
+ _sa_total_bytes = 0
3153
+ _sa_chunk_count = 0
3154
+ _sa_text_count = 0
3155
+ _sa_first_byte_logged = False
3156
+
3157
+ def _on_subagent_text(text: str) -> None:
3158
+ nonlocal _sa_total_bytes, _sa_text_count, _sa_first_byte_logged
3159
+ try:
3160
+ _queue.put_nowait(("text", text))
3161
+ except asyncio.QueueFull:
3162
+ logger.warning(
3163
+ "%s on_text_chunk queue full (maxsize=1000) — dropping text chunk "
3164
+ "of %d bytes. Consider increasing max_subagent_timeout to allow "
3165
+ "faster processing.",
3166
+ _sa_log_prefix, len(text),
3167
+ )
3168
+ return
3169
+ _sa_text_count += 1
3170
+ _sa_total_bytes += len(text)
3171
+ if not _sa_first_byte_logged:
3172
+ _sa_first_byte_logged = True
3173
+ logger.debug(
3174
+ "%s on_text_chunk FIRST token bytes=%d (text_count so far=%d)",
3175
+ _sa_log_prefix, len(text), _sa_text_count,
3176
+ )
3177
+ if _sa_text_count % 20 == 0:
3178
+ logger.debug(
3179
+ "%s on_text_chunk progress text_calls=%d total_bytes=%d "
3180
+ "queued=%d",
3181
+ _sa_log_prefix, _sa_text_count, _sa_total_bytes,
3182
+ _queue.qsize(),
3183
+ )
3184
+
3185
+ sub_engine.on_text_chunk = _on_subagent_text
3186
+ logger.debug("%s wiring on_text_chunk → queue (sub_engine id=%x)",
3187
+ _sa_log_prefix, id(sub_engine))
3188
+
3189
+ async def _run_chat_stream() -> None:
3190
+ logger.debug("%s _run_chat_stream task started", _sa_log_prefix)
3191
+ try:
3192
+ async for chunk in sub_engine.chat_stream(prompt):
3193
+ await _queue.put(("event", chunk))
3194
+ except asyncio.CancelledError:
3195
+ logger.debug("%s _run_chat_stream CancelledError → queue(cancelled)",
3196
+ _sa_log_prefix)
3197
+ await _queue.put(("cancelled", None))
3198
+ except Exception as e:
3199
+ logger.exception("%s _run_chat_stream exception: %s", _sa_log_prefix, e)
3200
+ await _queue.put(("error", e))
3201
+ finally:
3202
+ logger.debug(
3203
+ "%s _run_chat_stream finally → queue(done) text=%d bytes=%d "
3204
+ "chunk_events=%d",
3205
+ _sa_log_prefix, _sa_text_count, _sa_total_bytes, _sa_chunk_count,
3206
+ )
3207
+ await _queue.put(("done", None))
3208
+
3209
+ _stream_task = asyncio.create_task(_run_chat_stream())
3210
+ logger.debug("%s created _stream_task id=%x cancelled_flag=%s",
3211
+ _sa_log_prefix, id(_stream_task), self._cancelled)
3212
+
3213
+ parts: list[str] = []
3214
+ _streamed_via_text = False # True once on_text_chunk has delivered tokens
3215
+
3216
+ try:
3217
+ logger.debug("%s entering queue.get() consumer loop", _sa_log_prefix)
3218
+ _sa_deadline = subagent_timeout
3219
+ while True:
3220
+ if self._cancelled:
3221
+ logger.debug("%s cancelled — breaking consumer loop", _sa_log_prefix)
3222
+ yield (StreamEvent.subagent_end(
3223
+ "", agent_type=agent_type, status="failed", error="cancelled",
3224
+ ), "")
3225
+ return
3226
+ try:
3227
+ item_type, item = await asyncio.wait_for(
3228
+ _queue.get(),
3229
+ timeout=min(1.0, _sa_deadline),
3230
+ )
3231
+ _sa_deadline = subagent_timeout # reset on each item
3232
+ except asyncio.TimeoutError:
3233
+ _sa_deadline -= 1.0
3234
+ if _sa_deadline <= 0:
3235
+ logger.warning(
3236
+ "%s subagent timed out after %ds — cancelling",
3237
+ _sa_log_prefix, subagent_timeout,
3238
+ )
3239
+ yield (StreamEvent.subagent_end(
3240
+ "", agent_type=agent_type, status="failed",
3241
+ error=f"timed out after {subagent_timeout}s",
3242
+ ), "")
3243
+ return
3244
+ continue
3245
+ _sa_chunk_count += 1
3246
+ if _sa_chunk_count <= 5 or _sa_chunk_count % 50 == 0:
3247
+ logger.debug(
3248
+ "%s queue.get() #%d type=%s cancelled_flag=%s qsize=%d "
3249
+ "text=%d bytes=%d",
3250
+ _sa_log_prefix, _sa_chunk_count, item_type,
3251
+ self._cancelled, _queue.qsize(), _sa_text_count,
3252
+ _sa_total_bytes,
3253
+ )
3254
+
3255
+ if item_type == "text":
3256
+ _streamed_via_text = True
3257
+ parts.append(item)
3258
+ yield (StreamEvent.text_delta(item), item)
3259
+
3260
+ elif item_type == "event":
3261
+ chunk = item
3262
+ if isinstance(chunk, StreamEvent):
3263
+ if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
3264
+ # Skip TEXT_DELTA if tokens were already streamed
3265
+ # in real-time via on_text_chunk (avoids doubles).
3266
+ if not _streamed_via_text:
3267
+ if _sa_chunk_count <= 5:
3268
+ logger.debug(
3269
+ "%s TEXT_DELTA via event path (no "
3270
+ "streamed_via_text) bytes=%d",
3271
+ _sa_log_prefix, len(chunk.text),
3272
+ )
3273
+ parts.append(chunk.text)
3274
+ yield (chunk, chunk.text)
3275
+ else:
3276
+ if _sa_chunk_count <= 3:
3277
+ logger.debug(
3278
+ "%s TEXT_DELTA skipped (already "
3279
+ "streamed_via_text) bytes=%d",
3280
+ _sa_log_prefix, len(chunk.text),
3281
+ )
3282
+ elif chunk.type == StreamEventType.THINKING and chunk.thinking:
3283
+ logger.debug("%s THINKING event bytes=%d",
3284
+ _sa_log_prefix, len(chunk.thinking))
3285
+ yield (chunk, chunk.thinking)
3286
+ elif chunk.type == StreamEventType.TOOL_CALL_START:
3287
+ logger.debug("%s TOOL_CALL_START tool=%s",
3288
+ _sa_log_prefix, chunk.tool_name)
3289
+ yield (StreamEvent.subagent_tool_call(
3290
+ subagent_id, "start", chunk.tool_id,
3291
+ chunk.tool_name, chunk.tool_category,
3292
+ ), "")
3293
+ elif chunk.type == StreamEventType.TOOL_CALL_COMPLETE:
3294
+ logger.debug("%s TOOL_CALL_COMPLETE tool=%s",
3295
+ _sa_log_prefix, chunk.tool_name)
3296
+ yield (StreamEvent.subagent_tool_call(
3297
+ subagent_id, "complete", chunk.tool_id,
3298
+ chunk.tool_name, chunk.tool_category,
3299
+ chunk.tool_args,
3300
+ ), "")
3301
+ elif chunk.type == StreamEventType.TOOL_RESULT:
3302
+ logger.debug("%s TOOL_RESULT tool=%s",
3303
+ _sa_log_prefix, chunk.tool_name)
3304
+ yield (StreamEvent.subagent_tool_result(
3305
+ subagent_id, chunk.tool_id, chunk.tool_name,
3306
+ chunk.result_content, chunk.result_is_error,
3307
+ chunk.result_category,
3308
+ ), "")
3309
+ elif chunk.type == StreamEventType.ERROR:
3310
+ logger.debug("%s ERROR event msg=%s",
3311
+ _sa_log_prefix, chunk.error_message)
3312
+ text = f"\n[Error: {chunk.error_message}]\n"
3313
+ yield (StreamEvent.text_delta(text), text)
3314
+ else:
3315
+ if chunk:
3316
+ parts.append(chunk)
3317
+
3318
+ elif item_type == "done":
3319
+ logger.debug("%s queue.get()=done → break loop", _sa_log_prefix)
3320
+ break
3321
+
3322
+ elif item_type == "cancelled":
3323
+ logger.debug(
3324
+ "%s queue.get()=cancelled → yield subagent_end "
3325
+ "failed=cancelled", _sa_log_prefix)
3326
+ yield (StreamEvent.subagent_end(
3327
+ "", agent_type=agent_type, status="failed", error="cancelled",
3328
+ ), "")
3329
+ return
3330
+
3331
+ elif item_type == "error":
3332
+ logger.exception("%s queue.get()=error: %s", _sa_log_prefix, item)
3333
+ err_text = (
3334
+ f"SUMMARY:\nSub-agent failed with error: {item}\n\n"
3335
+ "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
3336
+ f"BLOCKERS:\n{item}"
3337
+ )
3338
+ yield (StreamEvent.subagent_end(
3339
+ "", agent_type=agent_type, status="failed", error=str(item),
3340
+ ), err_text)
3341
+ return
3342
+
3343
+ except asyncio.CancelledError:
3344
+ logger.debug("%s consumer loop CancelledError → subagent_end", _sa_log_prefix)
3345
+ # Parent cancelled — emit subagent_end so the TUI widget
3346
+ # transitions out of "running", then re-raise.
3347
+ yield (StreamEvent.subagent_end(
3348
+ "", agent_type=agent_type, status="failed", error="cancelled",
3349
+ ), "")
3350
+ raise
3351
+ finally:
3352
+ logger.debug(
3353
+ "%s consumer finally: stream_task.done=%s cancelled_flag=%s "
3354
+ "text=%d bytes=%d chunk_events=%d",
3355
+ _sa_log_prefix, _stream_task.done(), self._cancelled,
3356
+ _sa_text_count, _sa_total_bytes, _sa_chunk_count,
3357
+ )
3358
+ if not _stream_task.done():
3359
+ logger.debug("%s finally: cancelling _stream_task (still running)",
3360
+ _sa_log_prefix)
3361
+ _stream_task.cancel()
3362
+ try:
3363
+ logger.debug("%s finally: awaiting _stream_task ...",
3364
+ _sa_log_prefix)
3365
+ await _stream_task
3366
+ logger.debug("%s finally: await _stream_task returned cleanly",
3367
+ _sa_log_prefix)
3368
+ except asyncio.CancelledError:
3369
+ logger.debug("%s finally: await _stream_task CancelledError",
3370
+ _sa_log_prefix)
3371
+ except Exception as e:
3372
+ logger.debug("%s finally: await _stream_task exception=%s",
3373
+ _sa_log_prefix, e)
3374
+ if sub_engine in self._child_engines:
3375
+ self._child_engines.remove(sub_engine)
3376
+ logger.debug(
3377
+ "%s finally done text=%d bytes=%d chunk_events=%d",
3378
+ _sa_log_prefix, _sa_text_count, _sa_total_bytes, _sa_chunk_count,
3379
+ )
3380
+
3381
+ # Normal completion
3382
+ yield (StreamEvent.subagent_end(
3383
+ "", agent_type=agent_type, status="completed",
3384
+ ), "".join(parts))
3385
+
3386
+ _SUBAGENT_SECTION_RE = re.compile(
3387
+ r'^(SUMMARY|CHANGES|EVIDENCE|RISKS|BLOCKERS):\s*$',
3388
+ re.IGNORECASE | re.MULTILINE,
3389
+ )
3390
+
3391
+ def _format_subagent_output(self, agent_type: str, prompt: str, result: str) -> str:
3392
+ """Format sub-agent output in DeepSeek-TUI structured contract format.
3393
+
3394
+ Output contract:
3395
+ SUMMARY: one paragraph; what you did and what happened
3396
+ CHANGES: files modified, with one-line descriptions; "None." if read-only
3397
+ EVIDENCE: path:line-range citations and key findings; one bullet each
3398
+ RISKS: what could go wrong / what the parent should double-check
3399
+ BLOCKERS: what stopped you; "None." if you finished cleanly
3400
+ """
3401
+ # Detect structured sections via line-start regex, not substring match
3402
+ has_sections = self._SUBAGENT_SECTION_RE.search(result) is not None
3403
+
3404
+ if has_sections:
3405
+ return result
3406
+
3407
+ return (
3408
+ f"SUMMARY:\n{agent_type.capitalize()} agent completed task: {prompt[:200]}\n\n"
3409
+ f"CHANGES:\nNone detected.\n\n"
3410
+ f"EVIDENCE:\n- See detailed output below\n\n"
3411
+ f"RISKS:\nNone identified.\n\n"
3412
+ f"BLOCKERS:\nNone.\n\n"
3413
+ f"--- Raw Output ---\n{result}"
3414
+ )
3415
+
3416
+ def spawn_subagent(self, agent_type: str, prompt: str) -> dict:
3417
+ if self.current_agent.permission_task == AgentPermission.DENY:
3418
+ return {"success": False, "error": "Subagent denied"}
3419
+ import asyncio
3420
+ try:
3421
+ result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
3422
+ except Exception as e:
3423
+ logger.exception(f"Subagent error: {e}")
3424
+ return {"success": False, "error": safe_error_msg(e)}
3425
+ return {
3426
+ "success": True,
3427
+ "agent_type": agent_type,
3428
+ "response": result,
3429
+ "iterations": 1,
3430
+ }
3431
+
3432
+ def tool_task(self, agent_type: str, prompt: str) -> str:
3433
+ result = self.spawn_subagent(agent_type, prompt)
3434
+ if result["success"]:
3435
+ return result["response"]
3436
+ return f"Error: {result.get('error') or 'Unknown error'}"
3437
+
3438
+ def attach_session(self, session: AgentSession) -> None:
3439
+ self._session = session
3440
+ if session.messages:
3441
+ self.context.load_from_session(session.messages)
3442
+ if session.todos:
3443
+ self._todo_manager.reload_from_dict({
3444
+ "todos": session.todos,
3445
+ "id_counter": len(session.todos),
3446
+ })
3447
+ # Restore context usage stats
3448
+ self._restore_session_stats(session)
3449
+
3450
+ def save_session(self) -> None:
3451
+ if self._session:
3452
+ try:
3453
+ self._session.messages = self.context.to_session_format()
3454
+ tm_data = self._todo_manager.to_dict()
3455
+ self._session.todos = tm_data.get("todos", [])
3456
+ # Persist context usage stats so they survive session reload
3457
+ self._session.update_state("stats", {
3458
+ "total_tokens": self.total_tokens,
3459
+ "iterations": self.iterations,
3460
+ "turn_usages": [
3461
+ {k: v for k, v in u.items() if isinstance(v, (int, float))}
3462
+ for u in self._turn_usages
3463
+ ],
3464
+ })
3465
+ self._session.save()
3466
+ except Exception as e:
3467
+ logger.exception("Failed to save session: %s", e)
3468
+
3469
+ def load_session(self, session_id: str) -> bool:
3470
+ session = AgentSession(session_id)
3471
+ if not session.load():
3472
+ return False
3473
+ self._session = session
3474
+ self.context.load_from_session(session.messages)
3475
+ if session.todos:
3476
+ self._todo_manager.reload_from_dict({
3477
+ "todos": session.todos,
3478
+ "id_counter": len(session.todos),
3479
+ })
3480
+ self._inject_loaded_tasks_into_context()
3481
+ # Restore context usage stats
3482
+ self._restore_session_stats(session)
3483
+ return True
3484
+
3485
+ def _inject_loaded_tasks_into_context(self) -> None:
3486
+ """Surface loaded todos to the LLM so it can continue them.
3487
+
3488
+ Todos loaded from a previous session are not automatically visible
3489
+ in the LLM context — without this nudge the agent would re-create
3490
+ them on every fresh turn instead of resuming pending work.
3491
+ """
3492
+ all_todos = self._todo_manager.list_todos()
3493
+ pending = [t for t in all_todos if t.get("status") in ("pending", "in_progress")]
3494
+ if not pending:
3495
+ return
3496
+
3497
+ lines = ["# Resumed todos from previous session", ""]
3498
+ lines.append("## Todos")
3499
+ for t in pending:
3500
+ status = t.get("status", "pending")
3501
+ marker = {"in_progress": "[~]", "pending": "[ ]"}.get(status, "[?]")
3502
+ subject = t.get("subject") or t.get("description", "")
3503
+ desc = t.get("description") or ""
3504
+ if desc and desc != subject:
3505
+ lines.append(f"- {marker} (id={t.get('id')}) {subject} — {desc}")
3506
+ else:
3507
+ lines.append(f"- {marker} (id={t.get('id')}) {subject}")
3508
+ lines.append("")
3509
+
3510
+ lines.append(
3511
+ "Continue working on any in_progress or pending todos above. "
3512
+ "Use TodoList/TodoGet to inspect details and TodoUpdate to "
3513
+ "advance status. Do NOT recreate todos that already exist."
3514
+ )
3515
+ marker = "<!-- loaded_todos_resume -->"
3516
+ body = f"{marker}\n" + "\n".join(lines)
3517
+ if not self.context.replace_system_section(marker, body):
3518
+ self.context.add_system(body)
3519
+
3520
+ def _restore_session_stats(self, session: AgentSession) -> None:
3521
+ """Restore context usage stats from session lifecycle_state."""
3522
+ stats = session.get_state("stats", {})
3523
+ if not stats:
3524
+ return
3525
+ self.total_tokens = int(stats.get("total_tokens", self.total_tokens))
3526
+ self.iterations = int(stats.get("iterations", self.iterations))
3527
+ restored_turns = stats.get("turn_usages", [])
3528
+ if restored_turns and isinstance(restored_turns, list):
3529
+ self._turn_usages = list(restored_turns)
3530
+
3531
+ def save_todos_to_project(self) -> None:
3532
+ """Save todos to .cdh/todos.json — uses find_cdh_dir_for_todos
3533
+ so sub-projects never overwrite a parent project's todos."""
3534
+ from onecode.agent.cdh_loader import CdhProjectLoader
3535
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
3536
+ if cdh_dir is None:
3537
+ cdh_dir = self._project_dir / CdhProjectLoader.CDH_DIRNAME
3538
+ try:
3539
+ cdh_dir.mkdir(parents=True, exist_ok=True)
3540
+ except Exception as e:
3541
+ logger.warning("Failed to create .cdh directory: %s", e)
3542
+ return
3543
+ try:
3544
+ tm_data = self._todo_manager.to_dict()
3545
+ CdhProjectLoader.save_todos(cdh_dir, tm_data)
3546
+ except Exception as e:
3547
+ logger.warning("Failed to save todos to .cdh: %s", e)
3548
+
3549
+ def load_todos_from_project(self) -> None:
3550
+ """Restore todos from .cdh/todos.json — uses find_cdh_dir_for_todos
3551
+ so sub-projects never accidentally load a parent project's todos.
3552
+ If todos are found, the ``<!-- NEW_SESSION_HINT -->`` marker is
3553
+ removed from the system context (it only applies to blank sessions)."""
3554
+ from onecode.agent.cdh_loader import CdhProjectLoader
3555
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
3556
+ if cdh_dir is None:
3557
+ return
3558
+ try:
3559
+ data = CdhProjectLoader.load_todos(cdh_dir)
3560
+ if data and (data.get("tasks") or data.get("todos")):
3561
+ self._todo_manager.reload_from_dict(data)
3562
+ self.context.remove_system_by_marker("<!-- NEW_SESSION_HINT -->")
3563
+ except Exception as e:
3564
+ logger.warning("Failed to load todos from .cdh: %s", e)
3565
+
3566
+ def get_session(self) -> Optional[AgentSession]:
3567
+ return self._session